Compare commits
3
Commits
50fe4828a2
...
46d3a6fd41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46d3a6fd41 | ||
|
|
5655fa8093 | ||
|
|
b3b1d47dd6 |
No files matched your search
@@ -261,6 +261,13 @@ Each exists because something was invisible without it.
|
||||
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.
|
||||
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features
|
||||
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK
|
||||
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and
|
||||
**`iris/android-app/run-bench.sh [--apk PATH]`** installs it on this
|
||||
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
||||
report -- written so the P0 build/install/tap/read-report cycle stops
|
||||
being retyped by hand each time (docs/RUST.md's P0 box).
|
||||
|
||||
### Driving the UI
|
||||
|
||||
|
||||
@@ -437,3 +437,50 @@ with a number instead of a guess (RUST.md's I5 box).
|
||||
blocked handing the frame to the driver," not a confirmed GPU-completion
|
||||
time. Enough to separate "iris is slow building the frame" from "iris is
|
||||
slow handing it off," not enough to claim an exact GPU budget.
|
||||
|
||||
## 2026-09-05: `List::replace_back`/`List::clear`, and `TranscriptScreen::apply`
|
||||
|
||||
Fixes the "every client refolds and rebuilds the whole widget tree per
|
||||
streamed event" cost RUST.md's P0 box measured (20 events/second against a
|
||||
~3,200-row transcript). Two small additions to `iris::widget::List`
|
||||
(`iris/src/widget/list.rs`), plus one new method on `transcript-ui`'s
|
||||
`TranscriptScreen`.
|
||||
|
||||
- **`List::replace_back(row: ListRow) -> Option<ListRow>`**: swaps the
|
||||
*last* row's widget for a new one without moving it — same slot index,
|
||||
so an anchor already pinned there (in particular a list flush with its
|
||||
own end) stays pinned, and a `List` scrolled elsewhere is untouched.
|
||||
`None` if the list is empty. `RowKey` may differ between the old and new
|
||||
row; only `heights`/`extents` care, and both are invalidated for the
|
||||
evicted key the same way `pop_back` already does.
|
||||
- **`List::clear()`**: drops every loaded row and resets to `List::new`'s
|
||||
state (`more_before`/`more_after` untouched — a caller that wants those
|
||||
cleared too calls `set_more_before(None)`/`set_more_after(None)` itself).
|
||||
The fallback path for a change that touches more than the tail.
|
||||
- **`transcript_ui::TranscriptScreen::apply(&self, rsc, old: &[TranscriptItem], new: &[TranscriptItem])`**:
|
||||
the incremental alternative to rebuilding the whole screen from
|
||||
`transcript_ui::build_tree` on every folded event. Diffs the two
|
||||
`group_tool_runs` outputs and picks the cheapest update: nothing changed
|
||||
(no-op), a pure append (`push_row`, unchanged cost), or — the common
|
||||
streaming case, a delta into a still-open assistant message — a rebuild
|
||||
of just the one changed row via `List::replace_back`, with any further
|
||||
new rows appended after it. A row changing *before* the tail (only
|
||||
`group_tool_runs` retroactively grouping tool calls into a run does
|
||||
this) falls back to `List::clear` plus a full rebuild, counted in
|
||||
`TranscriptScreen::take_rebuilds()`. `bench_client.rs`, `transcript_client.rs`
|
||||
and `desktop-app/app.rs` all call this now instead of rebuilding on every
|
||||
event; only the opening page (and `apply`'s own fallback) still calls
|
||||
`build_tree`.
|
||||
- **`TextEditCtx::set_with_spans(text, spans)`**: `set()` plus a fresh
|
||||
`Vec<SpanStyle>` in one call, needed because a streamed row's markdown
|
||||
re-renders to both a new string and a new span list on every delta and
|
||||
the two have to land together — a stale span list drawn against new
|
||||
text can point past its end. `set()` itself is unchanged (still clears
|
||||
spans to none, as before).
|
||||
|
||||
Measured on this checkout's emulator (`iris/android-app/run-bench.sh`,
|
||||
release, x86_64, `force-gles`): worst-frame and p99 during the streaming
|
||||
phase dropped from 369.3ms/284.5ms (full rebuild per event, prior pass) to
|
||||
~101–130ms/~76–103ms across three runs (this fix) — see RUST.md's P0 box
|
||||
for the full numbers and the comparison's caveats (different AVD
|
||||
instances, not a controlled A/B on identical hardware state).
|
||||
+145
-16
@@ -36,6 +36,20 @@ session spending an afternoon on them again.
|
||||
|
||||
## Where things stand (2026-09-05)
|
||||
|
||||
- **Streaming no longer costs a full rebuild** (P0's box, "Streaming no
|
||||
longer costs a full rebuild" subsection): `iris::widget::List::
|
||||
replace_back`/`clear` plus `transcript_ui::TranscriptScreen::apply`
|
||||
replace the "refold + rebuild the whole ~3,200-row tree per event" path
|
||||
in all three clients. Worst/p99 frame time in the streaming phase
|
||||
dropped roughly 3x on this checkout's emulator (see the box for the
|
||||
exact numbers and their caveats). Blocked and not done this same pass:
|
||||
the three `iris-scroll.sh` runs, the host-GPU `FrameReport` retake, and
|
||||
the `EMU_GPU=software` cold boot the dropout-fix pass left open --
|
||||
`app/ui-sandbox.sh`'s `ai-server` currently fails to build
|
||||
(`event_model::Event::LimitReached` missing, unrelated to this pass's
|
||||
diff). Two new scripts, `iris/android-app/build-apk.sh` and
|
||||
`iris/android-app/run-bench.sh`, now do the build/install/tap/read-report
|
||||
cycle that used to be typed out by hand each time.
|
||||
- **The intermittent touch-scroll dropout is root-caused and fixed,
|
||||
2026-09-05.** Not the previously-suspected coalesced first
|
||||
`ACTION_MOVE` (ruled out) -- a gesture's `ACTION_DOWN` can land on a
|
||||
@@ -1515,20 +1529,23 @@ accepted.
|
||||
the bare REST fetch (no live stream yet) looked perfect on its own,
|
||||
and only *resuming* a stream after it exposed the seam.
|
||||
|
||||
**Deliberately left simple, and why** (`app.rs`'s module doc has the
|
||||
full account): every incoming SSE event refolds the session's whole
|
||||
item list and rebuilds the entire right-hand widget tree from
|
||||
scratch, rather than reaching for `TranscriptScreen::push_row`'s
|
||||
incremental append -- `push_row` can only add a new row, and a
|
||||
streaming reply is exactly a row whose text keeps changing after it
|
||||
first appears. Fine at the size a desktop session's conversation
|
||||
is; wrong for a long, fast-streaming one, and the real fix needs
|
||||
`transcript-ui` to expose updating a row already on screen, which it
|
||||
does not yet. The composer's in-progress text is saved and restored
|
||||
across a rebuild so a reply streaming in while the reader is typing
|
||||
a followup doesn't erase it. No history paging (I3's job, reused
|
||||
as-is if this becomes permanent) and no scroll-position preservation
|
||||
across a rebuild -- both named rather than silently missing.
|
||||
**Deliberately left simple at the time, later fixed** (`app.rs`'s
|
||||
module doc has the full account): every incoming SSE event used to
|
||||
refold the session's whole item list and rebuild the entire
|
||||
right-hand widget tree from scratch on every event, rather than
|
||||
reaching for `TranscriptScreen::push_row`'s incremental append --
|
||||
`push_row` could only add a new row, and a streaming reply is
|
||||
exactly a row whose text keeps changing after it first appears.
|
||||
Fine at the size a desktop session's conversation is; wrong for a
|
||||
long, fast-streaming one -- fixed below (this same box's "Streaming
|
||||
no longer costs a full rebuild" entry) by giving `transcript-ui` a
|
||||
`TranscriptScreen::apply` that updates a row already on screen
|
||||
instead of rebuilding every row around it. `rebuild_transcript`
|
||||
still runs the whole tree once, for a freshly loaded/selected
|
||||
session and for `apply`'s own rare full-rebuild fallback. No history
|
||||
paging (I3's job, reused as-is if this becomes permanent) and no
|
||||
scroll-position preservation across a rebuild -- both still named
|
||||
rather than silently missing, and neither depends on the fix below.
|
||||
Background network I/O runs on plain `std::thread`s reporting back
|
||||
through winit's `EventLoopProxy<AppEvent>` rather than iris's own
|
||||
`Tasks`/`task_on`, because `Tasks` only requests a redraw once after
|
||||
@@ -3581,8 +3598,12 @@ device.
|
||||
400 fixture events replay at 20/s through `fold_event` -- the same
|
||||
fold path a live SSE frame takes in `transcript_client.rs`'s own
|
||||
`apply_event` -- each one triggering `rebuild_transcript`'s full
|
||||
`transcript_ui::build_tree` rebuild, same tradeoff as
|
||||
`TranscriptClient`/`desktop-app`. A battery sampler runs
|
||||
`transcript_ui::build_tree` rebuild at the time this box was
|
||||
written, same tradeoff as `TranscriptClient`/`desktop-app`. **Fixed
|
||||
2026-09-05, later the same day**: all three now call
|
||||
`TranscriptScreen::apply` instead -- see this same section's
|
||||
"Streaming no longer costs a full rebuild" entry below for the
|
||||
before/after numbers. A battery sampler runs
|
||||
concurrently on its own `tokio::spawn`d task (not through
|
||||
`ctx.update`, since a JNI battery read needs no widget-tree access),
|
||||
attaching whichever thread it runs on via a stored `JavaVM` --
|
||||
@@ -3715,6 +3736,114 @@ device.
|
||||
branch was using (`iris/core/src/render/mod.rs`,
|
||||
`iris/src/android/render.rs`, `iris/src/default/render.rs`).
|
||||
|
||||
**Streaming no longer costs a full rebuild, 2026-09-05.** The gap
|
||||
named above and in E4/I5 (`push_row` can only append; a streaming
|
||||
reply is a row that keeps *changing* after it appears) is closed:
|
||||
`iris::widget::List` gained `replace_back` (swap the last row's
|
||||
widget in place, same slot, so a pinned-to-newest list stays pinned
|
||||
and an off-screen replace moves nothing on screen -- two new unit
|
||||
tests, `replacing_the_last_row_stays_pinned_to_the_bottom` and
|
||||
`replacing_the_last_row_out_of_view_does_not_move_visible_rows` in
|
||||
`iris/src/widget/list.rs`) and `clear` (drop every row, the fallback
|
||||
path). `transcript_ui::TranscriptScreen::apply(rsc, old_items,
|
||||
new_items)` diffs `group_tool_runs(old)`/`group_tool_runs(new)`
|
||||
(pure bookkeeping, no widget built doing it) and picks the cheapest
|
||||
update: unchanged (no-op), pure append (`push_row`, same as before),
|
||||
the common streaming case -- only the last row's content changed --
|
||||
rebuilds just that one row and swaps it in with `replace_back`, or
|
||||
(rare: `group_tool_runs` regrouping a row before the tail) a full
|
||||
`List::clear` rebuild, counted by `TranscriptScreen::take_rebuilds()`.
|
||||
Seven new unit tests in `transcript-ui/src/lib.rs`'s `diff_tests`
|
||||
cover all three cases directly against synthetic `Vec<FoldedRow>`s
|
||||
(no widget/Rsc needed for the decision itself). `bench_client.rs`,
|
||||
`transcript_client.rs` and `desktop-app/app.rs` all call `apply` now
|
||||
instead of rebuilding per event; `IRIS.md`'s 2026-09-05 entry has
|
||||
the full API account. `TextEditCtx` also gained `set_with_spans`
|
||||
(`iris/src/widget/text/edit.rs`) -- `set()` plus a fresh span list
|
||||
in one call, since a streamed row's re-rendered markdown needs both
|
||||
to land together.
|
||||
|
||||
**Two new scripts, `iris/android-app/build-apk.sh` and
|
||||
`iris/android-app/run-bench.sh`**, written this pass after repeating
|
||||
the ANDROID_HOME/NDK-export/`cargo ndk`/Gradle-release/keystore/
|
||||
apksigner incantation by hand one too many times. `build-apk.sh
|
||||
[debug|release] [--abi arm64-v8a|x86_64] [--features "..."]` builds
|
||||
the cdylib and the APK and verifies it (badging, and signing for a
|
||||
release build); `run-bench.sh [--apk PATH]` installs on this
|
||||
checkout's own emulator (`emu serial`), taps "Run benchmark" by
|
||||
label (no coordinates), polls logcat for the report line, and prints
|
||||
it. Used for everything below and for the redelivery at the end of
|
||||
this box.
|
||||
|
||||
**Numbers, this checkout's AVD (`ai-app-2`), release, x86_64,
|
||||
`force-gles`, via `run-bench.sh` -- three separate runs, same warm
|
||||
AVD (not a fresh cold boot each time):**
|
||||
|
||||
frames=690 janky%=78.26 p50=26.9ms p90=60.3ms p99=103.4ms worst=130.1ms cpu_p50=7.4ms gpu_wait_p50=15.7ms
|
||||
frames=691 janky%=84.95 p50=28.3ms p90=60.9ms p99=95.2ms worst=120.7ms cpu_p50=5.4ms gpu_wait_p50=18.2ms
|
||||
frames=691 janky%=58.32 p50=18.9ms p90=40.3ms p99=75.6ms worst=101.4ms cpu_p50=3.5ms gpu_wait_p50=12.5ms
|
||||
|
||||
Against this same box's earlier iris-half reading (full rebuild per
|
||||
event, a *different*, freshly-booted x86_64 AVD, host GPU):
|
||||
`frames=372 janky%=56.99 p50=19.5ms p90=219.5ms p99=284.5ms
|
||||
worst=369.3ms cpu_p50=0.4ms gpu_wait_p50=13.9ms`. The tail is what
|
||||
moved: `worst` dropped from 369.3ms to 101–130ms and `p99` from
|
||||
284.5ms to 76–103ms across all three post-fix runs, consistent with
|
||||
removing the periodic full-tree-rebuild stall during the
|
||||
20-events/second streaming phase. `p50`/`cpu_p50` are *not* a clean
|
||||
comparison -- these three runs share one already-warm AVD instance
|
||||
rather than each getting its own fresh cold boot the way the earlier
|
||||
reading did, and `cpu_p50` in particular is noisy run to run (3.5 to
|
||||
7.4ms here) in a way a controlled A/B would need to separate from
|
||||
the code change itself. **What a future pass should do for a clean
|
||||
number**: two fresh cold boots of the same AVD, one per build,
|
||||
`run-bench.sh` on each, nothing else running.
|
||||
|
||||
**The three remaining I5 verifications the dropout-root-cause pass
|
||||
left open (this box's "Touch-scroll dropout root-caused" subsection)
|
||||
-- attempted this pass, blocked, not silently dropped.**
|
||||
`app/iris-scroll.sh` needs `dev.iris.android.demo`'s plain
|
||||
`transcript-screen` (non-`bench`) debug build, which needs a live
|
||||
sandbox server (`app/ui-sandbox.sh`) to bake in at build time
|
||||
(`build.rs`'s `AI_APP_TRANSCRIPT_HOST`/etc, skipped only under
|
||||
`bench`). `./ui-sandbox.sh start` fails to build **the Rust
|
||||
`ai-server` itself**, unrelated to anything in this box's diff:
|
||||
`error[E0599]: no variant named 'LimitReached' found for enum
|
||||
'event_model::Event'` in `server/src/session/mod.rs:2633`,
|
||||
`server/src/session/claude/translate.rs:307`, and
|
||||
`server/src/session/echo.rs:389` -- `event_model` and `server` have
|
||||
drifted out of sync on this branch, most likely from concurrent
|
||||
work elsewhere on `rustify` (this pass touched nothing under
|
||||
`server/` or `event-model/`, confirmed by `git status`). Since
|
||||
fixing that is a separate, unrelated repair (and risks colliding
|
||||
with whatever pass is mid-edit there), this pass did not attempt
|
||||
it. Consequently, not done this pass: the three `iris-scroll.sh`
|
||||
runs, the host-GPU `FrameReport` table row retake, and the
|
||||
`EMU_GPU=software` + `force-gles` cold-boot `FrameReport` (the
|
||||
backend-isolation question) -- none of the three need the broken
|
||||
server directly, but the first two need the same debug build the
|
||||
server outage blocks, and by the time that was found there was not
|
||||
enough of this pass left to justify a fresh `EMU_GPU=software` cold
|
||||
boot (several minutes) for the third alone without also covering the
|
||||
other two on the same session. A future pass: fix or wait out the
|
||||
`server`/`event_model` drift, rebuild `dev.iris.android.demo` with
|
||||
plain `transcript-screen` via `./build-apk.sh debug --abi x86_64
|
||||
--features "transcript-screen force-gles"`, then run
|
||||
`app/iris-scroll.sh` three times and retake the host-GPU row, and
|
||||
separately cold-boot with `EMU_GPU=software` for the third.
|
||||
|
||||
**Redelivered, 2026-09-05.** `./build-apk.sh release --abi
|
||||
arm64-v8a` (arm64-only jniLibs; an earlier step in this same pass
|
||||
had left an x86_64 slice in there from the emulator testing above,
|
||||
removed before this build so the delivered APK matches P0's
|
||||
original arm64-only shape) -- `aapt2 dump badging` confirms
|
||||
`native-code: 'arm64-v8a'` and the same
|
||||
`dev.iris.android.demo.bench` id, `apksigner verify` the same
|
||||
`CN=ai-app` cert as before. Copied over
|
||||
`~/host/bench/iris-bench-arm64.apk`; `~/host/bench/README.md` gained
|
||||
a one-line build-date/commit note so Iris can tell which build she
|
||||
has.
|
||||
|
||||
- [ ] **P1 — session screen parity.** History paging backward (with the
|
||||
page-boundary healing `client-core` does not have yet, below),
|
||||
`TranscriptSource`-backed cache/server stitching, jump-to-latest,
|
||||
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/bin/sh
|
||||
# Builds iris-android-app end to end: the cdylib (cargo ndk, straight into
|
||||
# app/src/main/jniLibs/) then the APK (Gradle). Written to stop re-typing
|
||||
# the same incantation by hand every time (ANDROID_HOME/NDK exports, the
|
||||
# cargo ndk invocation, the keystore env for a release build, apksigner/
|
||||
# aapt2 verification) -- see docs/RUST.md's P0 box. Same shape as `app/
|
||||
# build-apk.sh` (the Compose app's own build script) and `app/
|
||||
# iris-scroll.sh` (no coordinates, set -eu, exit 0 on success).
|
||||
#
|
||||
# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"]
|
||||
# debug/release default to debug (matches this-machine-android's "the
|
||||
# emulator stays on debug" rule -- pass `release` explicitly for a phone
|
||||
# build). --abi defaults to arm64-v8a (a phone/real device); pass
|
||||
# x86_64 for this checkout's own AVD. --features defaults to
|
||||
# "transcript-screen force-gles bench", P0's exact combination.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BUILD_TYPE="debug"
|
||||
ABI="arm64-v8a"
|
||||
FEATURES="transcript-screen force-gles bench"
|
||||
case "${1:-}" in
|
||||
debug|release) BUILD_TYPE="$1"; shift ;;
|
||||
esac
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--abi) ABI="$2"; shift 2 ;;
|
||||
--features) FEATURES="$2"; shift 2 ;;
|
||||
*) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SDK_ROOT="$HOME/Android/Sdk"
|
||||
export ANDROID_HOME="$SDK_ROOT"
|
||||
export ANDROID_SDK_ROOT="$SDK_ROOT"
|
||||
NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1)
|
||||
if [ -z "$NDK_DIR" ]; then
|
||||
echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2
|
||||
exit 1
|
||||
fi
|
||||
export ANDROID_NDK_HOME="$NDK_DIR"
|
||||
|
||||
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
||||
else
|
||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
||||
fi
|
||||
|
||||
GRADLE_TASK="assembleDebug"
|
||||
APK_DIR="app/build/outputs/apk/debug"
|
||||
APK_NAME="app-debug.apk"
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
GRADLE_TASK="assembleRelease"
|
||||
APK_DIR="app/build/outputs/apk/release"
|
||||
APK_NAME="app-release.apk"
|
||||
# Same key `app/build-apk.sh` (the Compose app) generates once under
|
||||
# ~/.config/ai-app/release.jks -- see AGENTS.md's "Checking your work".
|
||||
export AI_APP_KEYSTORE="$HOME/.config/ai-app/release.jks"
|
||||
if [ ! -f "$AI_APP_KEYSTORE" ]; then
|
||||
echo "build-apk.sh: no release key at $AI_APP_KEYSTORE -- run app/build-apk.sh once first" >&2
|
||||
exit 1
|
||||
fi
|
||||
export AI_APP_KEYSTORE_PASSWORD
|
||||
AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password")
|
||||
fi
|
||||
|
||||
gradle ":app:$GRADLE_TASK" --console=plain
|
||||
|
||||
APK_PATH="$(pwd)/$APK_DIR/$APK_NAME"
|
||||
BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1)
|
||||
echo "--- aapt2 dump badging ---"
|
||||
"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
echo "--- apksigner verify ---"
|
||||
"${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH"
|
||||
fi
|
||||
echo "$APK_PATH"
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/sh
|
||||
# Installs and runs the iris `bench` build on this checkout's own emulator
|
||||
# (per this-machine-android's per-checkout-AVD rule; `emu serial` picks it)
|
||||
# and prints the report -- the iris half of `app/transcript-bench.sh`'s
|
||||
# job. No coordinates: the button is found by its accessibility label
|
||||
# through `ui-trace`, per AGENTS.md's "Driving the UI".
|
||||
#
|
||||
# Usage: ./run-bench.sh [--apk PATH]
|
||||
# Defaults to this checkout's own release APK
|
||||
# (app/build/outputs/apk/release/app-release.apk) if it exists, else the
|
||||
# debug one -- build one first with ./build-apk.sh.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APK=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--apk) APK="$2"; shift 2 ;;
|
||||
*) echo "run-bench.sh: unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
if [ -z "$APK" ]; then
|
||||
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
|
||||
APK=app/build/outputs/apk/release/app-release.apk
|
||||
else
|
||||
APK=app/build/outputs/apk/debug/app-debug.apk
|
||||
fi
|
||||
fi
|
||||
if [ ! -f "$APK" ]; then
|
||||
echo "run-bench.sh: no APK at $APK -- run ./build-apk.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERIAL=$(emu serial)
|
||||
PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
|
||||
if [ -z "$PKG" ]; then
|
||||
BUILD_TOOLS=$(ls -d "$HOME"/Android/Sdk/build-tools/*/ | sort -V | tail -1)
|
||||
PKG=$("${BUILD_TOOLS}aapt2" dump badging "$APK" | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
|
||||
fi
|
||||
|
||||
echo "run-bench.sh: installing $APK ($PKG) on $SERIAL"
|
||||
adb -s "$SERIAL" install -r "$APK" >/dev/null
|
||||
adb -s "$SERIAL" shell am force-stop "$PKG"
|
||||
adb -s "$SERIAL" logcat -c
|
||||
adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/dev/null
|
||||
|
||||
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
|
||||
|
||||
# Poll for the report line rather than a fixed sleep -- the run itself is a
|
||||
# fixed script (24 swipes + a 20s streaming phase) but device speed varies.
|
||||
i=0
|
||||
while [ "$i" -lt 90 ]; do
|
||||
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true)
|
||||
if [ -n "$LINE" ]; then
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$LINE" ]; then
|
||||
echo "run-bench.sh: no report after 90s -- check logcat by hand" >&2
|
||||
exit 1
|
||||
fi
|
||||
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 6 "iris bench report:"
|
||||
@@ -4,17 +4,21 @@
|
||||
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
||||
//! that measures the renderer rather than the data or the network.
|
||||
//!
|
||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, a full
|
||||
//! `transcript_ui::build_tree` rebuild per event) with the network half
|
||||
//! replaced by the checked-in fixture, embedded with `include_str!` --
|
||||
//! `app/bench-fixture/assets/transcript.jsonl`, 1,915,760 bytes, generated
|
||||
//! by `app/bench-fixture/generate.py` and never a real transcript (that
|
||||
//! file's own README). The first 3,200 lines are the opening backlog,
|
||||
//! folded once through `client_core::transcript_fold::fold_page` exactly
|
||||
//! as a real `/transcript` page would be; the remaining ~400 are the
|
||||
//! streaming tail, replayed one at a time through `fold_event` -- the same
|
||||
//! fold path a live SSE reply arrives on -- by the "Run benchmark"
|
||||
//! control below.
|
||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
||||
//! `TranscriptScreen::apply` incremental update on every event) with the
|
||||
//! network half replaced by the checked-in fixture, embedded with
|
||||
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`,
|
||||
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never
|
||||
//! a real transcript (that file's own README). The first 3,200 lines are
|
||||
//! the opening backlog, folded once through
|
||||
//! `client_core::transcript_fold::fold_page` exactly as a real
|
||||
//! `/transcript` page would be (then a full `transcript_ui::build_tree`,
|
||||
//! same as any first load); the remaining ~400 are the streaming tail,
|
||||
//! replayed one at a time through `fold_event` -- the same fold path a
|
||||
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
||||
//! Streaming through `apply` rather than a full rebuild per event is what
|
||||
//! this file exists to measure -- see docs/RUST.md's P0 box for the
|
||||
//! before/after report.
|
||||
|
||||
use crate::bench_jni::PlatformHandle;
|
||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||
@@ -344,8 +348,15 @@ impl BenchClient {
|
||||
let mut sent = 0usize;
|
||||
for event in stream_tail.into_iter().take(total) {
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
let old_items = state.items.clone();
|
||||
state.items = fold_event(&state.items, &event);
|
||||
state.rebuild_transcript(rsc);
|
||||
match &state.screen {
|
||||
// The path P0 asked to measure: update only the
|
||||
// row(s) that changed instead of rebuilding all
|
||||
// ~3,200 of them per event.
|
||||
Some(screen) => screen.apply(rsc, &old_items, &state.items),
|
||||
None => state.rebuild_transcript(rsc),
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
sent += 1;
|
||||
|
||||
@@ -20,14 +20,22 @@
|
||||
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
||||
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
||||
//! `client_core::transcript_fold`, a `generation` counter guarding against
|
||||
//! a stale background response, and a full rebuild of the widget tree on
|
||||
//! every event (same tradeoff, same reason: `push_row` cannot update a row
|
||||
//! already on screen, and this rig's conversations are small). What
|
||||
//! differs is only the redraw mechanism: android-view has no
|
||||
//! `winit::EventLoopProxy`, so this uses `iris::task::Tasks::redraw_handle`
|
||||
//! (new, added alongside this box) to request a frame after each
|
||||
//! `TaskCtx::update` instead of relying on `Tasks::spawn`'s single
|
||||
//! end-of-future redraw -- see that method's own doc for why.
|
||||
//! a stale background response. What differs is only the redraw
|
||||
//! mechanism: android-view has no `winit::EventLoopProxy`, so this uses
|
||||
//! `iris::task::Tasks::redraw_handle` (new, added alongside this box) to
|
||||
//! request a frame after each `TaskCtx::update` instead of relying on
|
||||
//! `Tasks::spawn`'s single end-of-future redraw -- see that method's own
|
||||
//! doc for why.
|
||||
//!
|
||||
//! **Streaming no longer costs a full rebuild** (fixed after the P0 gate
|
||||
//! showed why it mattered -- 20 events/second means 20 rebuilds/second of
|
||||
//! a ~3,200-row transcript otherwise): `apply_event` calls
|
||||
//! `transcript_ui::TranscriptScreen::apply` with the item list before and
|
||||
//! after `fold_event`, which updates only the row(s) that actually
|
||||
//! changed (almost always just the one open assistant message) instead of
|
||||
//! refolding and rebuilding every row. `rebuild_transcript` still runs
|
||||
//! the whole widget tree once, for the opening page and for `apply`'s own
|
||||
//! rare regroup fallback.
|
||||
|
||||
use client_core::api::{ApiClient, UreqTransport};
|
||||
use client_core::event_stream::{StreamItem, follow_session_events};
|
||||
@@ -360,8 +368,17 @@ impl TranscriptClient {
|
||||
}
|
||||
|
||||
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
|
||||
let old_items = self.items.clone();
|
||||
self.items = fold_event(&self.items, event);
|
||||
self.rebuild_transcript(rsc);
|
||||
match &self.screen {
|
||||
// The common path: update only the row(s) that actually
|
||||
// changed instead of refolding and rebuilding all ~3,200 of
|
||||
// them per event (RUST.md's P0 streaming-phase fix).
|
||||
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||
// No screen yet (the opening page hasn't landed) -- build one
|
||||
// the ordinary way once it has.
|
||||
None => self.rebuild_transcript(rsc),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(&mut self, session_id: String, text: String) {
|
||||
|
||||
+18
-18
@@ -15,23 +15,19 @@
|
||||
//! +-----------+--------------------------------------+
|
||||
//! ```
|
||||
//!
|
||||
//! **Deliberately left simple, and why**: every incoming SSE event refolds
|
||||
//! the *entire* transcript (`client_core::transcript_fold::fold_event` is
|
||||
//! already `O(items)` and a desktop session's conversation is small) and
|
||||
//! rebuilds the whole right-hand widget tree from scratch, rather than
|
||||
//! reaching for `TranscriptScreen::push_row`'s incremental append.
|
||||
//! `push_row` cannot update a row already on screen -- only append a new
|
||||
//! one -- and a streaming assistant reply is exactly a row whose *text*
|
||||
//! keeps changing after it first appears (see `transcript-ui`'s own doc on
|
||||
//! `fold_event` folding deltas into one growing item). A full rebuild
|
||||
//! shows that growth correctly at the cost of redrawing everything each
|
||||
//! time; fine for this proof, wrong for a long, fast-streaming transcript
|
||||
//! -- the incremental path that fixes it needs `transcript-ui` to expose
|
||||
//! updating a row in place, which it does not yet. The composer's
|
||||
//! in-progress text survives a rebuild (`rebuild_transcript`'s
|
||||
//! `in_progress` local) since the user typing a followup while a reply
|
||||
//! streams in is the one case a naive rebuild would otherwise lose data
|
||||
//! on.
|
||||
//! **Incoming SSE events go through `TranscriptScreen::apply`**, not a
|
||||
//! full rebuild: `client_core::transcript_fold::fold_event` folds the new
|
||||
//! item list as before, then `apply` updates only the row(s) that actually
|
||||
//! changed (almost always the one still-open assistant message a delta
|
||||
//! landed in) instead of rebuilding the whole right-hand widget tree from
|
||||
//! scratch. `rebuild_transcript` still runs the whole tree once, for a
|
||||
//! freshly loaded/selected session and for `apply`'s own rare
|
||||
//! full-rebuild fallback (a `group_tool_runs` regroup touching a row
|
||||
//! before the tail). The composer's in-progress text survives a rebuild
|
||||
//! (`rebuild_transcript`'s `in_progress` local) since the user typing a
|
||||
//! followup while a reply streams in is the one case a naive rebuild
|
||||
//! would otherwise lose data on -- `apply`'s own path never touches the
|
||||
//! composer at all, so this only matters on the fallback.
|
||||
//!
|
||||
//! Background network I/O (`client_core::api`/`event_stream`, both
|
||||
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
|
||||
@@ -209,8 +205,12 @@ impl DefaultAppState for Client {
|
||||
event,
|
||||
} => {
|
||||
if self.current(&session_id, generation) {
|
||||
let old_items = self.items.clone();
|
||||
self.items = fold_event(&self.items, &event);
|
||||
self.rebuild_transcript(rsc);
|
||||
match &self.screen {
|
||||
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||
None => self.rebuild_transcript(rsc),
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::StreamEnded {
|
||||
|
||||
@@ -317,6 +317,40 @@ impl List {
|
||||
self.extents.clear();
|
||||
}
|
||||
|
||||
/// Swap the last row's widget for a new one **without moving it**: the
|
||||
/// slot index is unchanged, so an anchor already pointing at this slot
|
||||
/// (in particular `snap_end`'s pinned-to-newest case) stays pinned, and
|
||||
/// an anchor pointing anywhere else -- this row scrolled out of view --
|
||||
/// is untouched, so nothing currently on screen moves. This is what a
|
||||
/// streamed reply needs: the row whose *content* keeps changing after
|
||||
/// it first appears is still the same row by position, even if its
|
||||
/// `RowKey` happens to change too (rare -- only `heights`/`extents` care
|
||||
/// about the key, and both are invalidated here the same way
|
||||
/// `pop_back` already invalidates them for the row it removes).
|
||||
/// `None` if the list is empty. O(1), same as `push_back`/`pop_back`.
|
||||
pub fn replace_back(&mut self, row: ListRow) -> Option<ListRow> {
|
||||
let idx = self.items.len().checked_sub(1)?;
|
||||
let old = std::mem::replace(&mut self.items[idx], row);
|
||||
self.heights.remove(&old.key);
|
||||
self.extents.clear();
|
||||
Some(old)
|
||||
}
|
||||
|
||||
/// Drop every loaded row and reset to the same state `List::new` would
|
||||
/// give -- the fallback path for a change `apply`-style incremental
|
||||
/// callers can't express as a replace-or-append (RUST.md: `group_tool_runs`
|
||||
/// regrouping an earlier row). `more_before`/`more_after` are left
|
||||
/// alone: a full paging reset is a different operation from "the
|
||||
/// content changed," and a caller that wants both calls
|
||||
/// `set_more_before(None)`/`set_more_after(None)` itself.
|
||||
pub fn clear(&mut self) {
|
||||
self.items.clear();
|
||||
self.anchor = None;
|
||||
self.snap_end = true;
|
||||
self.heights.clear();
|
||||
self.extents.clear();
|
||||
}
|
||||
|
||||
/// Move the anchor's edge by `amt` pixels. Positive moves later
|
||||
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
||||
/// Deliberately unclamped -- see the module doc's "what is not
|
||||
@@ -1032,4 +1066,115 @@ mod tests {
|
||||
assert!(moves <= 12, "n={n}: expected O(visible) moves, got {moves}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The streamed-reply case (RUST.md's "streaming still costs a full
|
||||
/// rebuild" fix, `transcript-ui::TranscriptScreen::apply`): a delta
|
||||
/// swaps the last row's widget for a taller one, same key, same slot.
|
||||
/// A list flush with its own end (the default, `snap_end`) must stay
|
||||
/// flush -- the row grows *upward* from the pinned bottom edge, not
|
||||
/// the other way around, exactly like an ordinary resize of that same
|
||||
/// row would (`expanding_a_row_holds_the_bottom_edge_when_tap_is_lower`).
|
||||
#[test]
|
||||
fn replacing_the_last_row_stays_pinned_to_the_bottom() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let mut list = List::new(Axis::Y);
|
||||
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
|
||||
let (list_weak, root) = add_list(&mut rsc, list);
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 60.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
// Row 4 is flush with the viewport's bottom edge before the replace.
|
||||
{
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert!((list_ref.extents[&4].bottom - 60.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
let (_weak, new_row) = fixed_row(&mut rsc, 40.0);
|
||||
let old = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.replace_back(ListRow::new(4, new_row));
|
||||
assert!(
|
||||
old.is_some(),
|
||||
"replace_back should hand back the row it evicted"
|
||||
);
|
||||
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
let row4 = list_ref.extents[&4];
|
||||
assert!(
|
||||
(row4.bottom - 60.0).abs() < 0.01,
|
||||
"still pinned to the newest end after the replace: {row4:?}"
|
||||
);
|
||||
assert!(
|
||||
(row4.top - 20.0).abs() < 0.01,
|
||||
"grew upward, from the pinned bottom edge: {row4:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the same fix's contract: replacing a row that is
|
||||
/// *not* on screen must not move anything that is. `replace_back` only
|
||||
/// touches the last slot's own widget and this file's own `heights`/
|
||||
/// `extents` caches for that one key -- nothing about `Anchor` changes
|
||||
/// -- so the already-placed rows above it should come out at the exact
|
||||
/// same boxes on the next frame.
|
||||
#[test]
|
||||
fn replacing_the_last_row_out_of_view_does_not_move_visible_rows() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let mut list = List::new(Axis::Y);
|
||||
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
|
||||
let (list_weak, root) = add_list(&mut rsc, list);
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 60.0));
|
||||
// Settle at the default (bottom) anchor first -- `jump_to_start`
|
||||
// does not touch `snap_end`, and `repair_anchor` only leaves a
|
||||
// freshly-set anchor's offset alone once `viewport_len` has
|
||||
// already matched `last_viewport_len` once, the same reason
|
||||
// `moves_stay_o1_across_list_size` settles before the tick it
|
||||
// actually measures.
|
||||
render.update(&root, &mut rsc);
|
||||
// Scrolled to the oldest content: rows 0,1,2 visible, row 4 is far
|
||||
// below the viewport.
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let (before0, before1, before2) = {
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert!(!list_ref.extents.contains_key(&4));
|
||||
(
|
||||
list_ref.extents[&0],
|
||||
list_ref.extents[&1],
|
||||
list_ref.extents[&2],
|
||||
)
|
||||
};
|
||||
|
||||
let (_weak, new_row) = fixed_row(&mut rsc, 999.0);
|
||||
rsc.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.replace_back(ListRow::new(4, new_row));
|
||||
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
for (key, before) in [(0u64, before0), (1, before1), (2, before2)] {
|
||||
let after = list_ref.extents[&key];
|
||||
assert_eq!(
|
||||
(after.top, after.bottom),
|
||||
(before.top, before.bottom),
|
||||
"row {key} moved after an off-screen replace"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,20 @@ impl<'a> TextEditCtx<'a> {
|
||||
self.text.selection = None;
|
||||
}
|
||||
|
||||
/// [`set`](Self::set) plus a fresh set of [`SpanStyle`]s in one call --
|
||||
/// what a streamed transcript row needs, since its markdown re-renders
|
||||
/// to a new string *and* a new span list on every delta and the two
|
||||
/// have to land together (a stale span list drawn against new text can
|
||||
/// point past its end). Used by `transcript-ui`'s incremental apply
|
||||
/// (RUST.md's "streaming still costs a full rebuild" fix) rather than
|
||||
/// tearing the row's widget down and rebuilding it from scratch.
|
||||
pub fn set_with_spans(&mut self, text: &str, spans: Vec<SpanStyle>) {
|
||||
let text = self.string(text);
|
||||
self.text.view.buf.set_text(text);
|
||||
self.text.view.buf.set_spans(spans);
|
||||
self.text.selection = None;
|
||||
}
|
||||
|
||||
pub fn motion(&mut self, motion: Motion, select: bool) {
|
||||
let Some(sel) = self.text.selection else {
|
||||
return;
|
||||
|
||||
@@ -60,6 +60,12 @@ pub struct TranscriptScreen {
|
||||
pub list: WeakWidget<List>,
|
||||
pub composer: composer::Composer,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
/// How many times [`Self::apply`] has fallen back to a full rebuild --
|
||||
/// `Cell` rather than requiring `&mut self`, matching every other
|
||||
/// method here (the real state lives behind `list`/`selection`'s own
|
||||
/// interior mutability, per `push_row`'s existing `&self`). Drained by
|
||||
/// [`Self::take_rebuilds`].
|
||||
rebuilds: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl TranscriptScreen {
|
||||
@@ -75,6 +81,93 @@ impl TranscriptScreen {
|
||||
(self.list)(rsc).push_back(ListRow::new(key, widget));
|
||||
}
|
||||
|
||||
/// Apply the effect of one more folded event without rebuilding the
|
||||
/// whole screen -- RUST.md's "streaming still costs a full rebuild"
|
||||
/// fix. `old`/`new` are `client_core::transcript_fold::fold_event`'s
|
||||
/// own before/after item lists (never grouped into rows -- that
|
||||
/// happens here, over both, so the common tail cases can be told
|
||||
/// apart; `group_tool_runs` is pure bookkeeping over already-folded
|
||||
/// items, no widget is built doing it).
|
||||
///
|
||||
/// Three cases, cheapest first:
|
||||
/// - **nothing changed**: no-op.
|
||||
/// - **pure append** (a still-open reply's row now closed and stable,
|
||||
/// a new tool call, a new message): every new row is `push_back`ed,
|
||||
/// same cost as [`Self::push_row`].
|
||||
/// - **only the last row's content changed** (the common case: a delta
|
||||
/// folded into a still-open assistant message): that one row is
|
||||
/// rebuilt (`row::build_row`, the same path a fresh row goes
|
||||
/// through) and swapped in with [`List::replace_back`] -- every
|
||||
/// other row is untouched, so nothing else redraws or moves. Any
|
||||
/// further new rows are appended after it, for the (also common)
|
||||
/// case of a delta that both finishes the open reply and starts the
|
||||
/// next row in the same event.
|
||||
///
|
||||
/// Anything else -- a row *before* the tail changed, which only
|
||||
/// happens when `group_tool_runs` regroups already-seen items (a tool
|
||||
/// run's calls that used to be separate rows join once the run closes)
|
||||
/// -- falls back to a full rebuild: every row is dropped
|
||||
/// (`List::clear`) and rebuilt from `new`. Counted in
|
||||
/// [`Self::take_rebuilds`] so a caller (a report, a test) can see how
|
||||
/// often the fallback actually fires rather than assuming it never
|
||||
/// does.
|
||||
pub fn apply<Rsc: HasEvents>(
|
||||
&self,
|
||||
rsc: &mut Rsc,
|
||||
old: &[client_core::transcript_fold::TranscriptItem],
|
||||
new: &[client_core::transcript_fold::TranscriptItem],
|
||||
) where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
use client_core::transcript_fold::group_tool_runs;
|
||||
|
||||
let old_rows = group_tool_runs(old);
|
||||
let new_rows = group_tool_runs(new);
|
||||
|
||||
match diff_rows(&old_rows, &new_rows) {
|
||||
RowDiff::Unchanged => {}
|
||||
RowDiff::Appended { common } => {
|
||||
// Pure append: every already-drawn row is byte-for-byte the
|
||||
// same `FoldedRow` it was last time.
|
||||
for row in &new_rows[common..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
RowDiff::ReplaceLast { common } => {
|
||||
// Only the tail row's content changed -- rebuild that one
|
||||
// row and swap it in place, keeping every row before it
|
||||
// untouched.
|
||||
let old_key = row::row_key(&old_rows[common].key());
|
||||
let (new_key, widget) =
|
||||
row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]);
|
||||
if new_key != old_key {
|
||||
self.selection.borrow_mut().unregister(old_key);
|
||||
}
|
||||
let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget));
|
||||
drop(evicted); // frees the old row's widget, same as a pop would
|
||||
for row in &new_rows[common + 1..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
RowDiff::Rebuild => {
|
||||
// A row before the tail changed (a regroup) -- nothing
|
||||
// short of a full rebuild expresses that.
|
||||
self.rebuilds.set(self.rebuilds.get() + 1);
|
||||
(self.list)(rsc).clear();
|
||||
for row in &new_rows {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many times [`Self::apply`] has fallen back to a full rebuild
|
||||
/// since the last call, reset to 0 by reading it -- the same
|
||||
/// take-and-reset shape `AccessTree::take_rebuilds` already uses (I4).
|
||||
pub fn take_rebuilds(&self) -> usize {
|
||||
self.rebuilds.replace(0)
|
||||
}
|
||||
|
||||
/// The concatenated text of whatever is currently selected across one
|
||||
/// or more rows, `None` if nothing is -- what a copy command reads.
|
||||
pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option<String> {
|
||||
@@ -139,7 +232,148 @@ where
|
||||
list,
|
||||
composer,
|
||||
selection,
|
||||
rebuilds: std::cell::Cell::new(0),
|
||||
},
|
||||
tree,
|
||||
)
|
||||
}
|
||||
|
||||
/// What changed at the tail between two folded row lists -- the decision
|
||||
/// [`TranscriptScreen::apply`] acts on. Kept as its own pure function, no
|
||||
/// widget and no `Rsc`, so the three cases can be tested directly against
|
||||
/// synthetic `Vec<FoldedRow>`s (below) rather than needing a full widget
|
||||
/// harness to exercise logic that never touches one.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum RowDiff {
|
||||
/// `old` and `new` are the same length and every row is identical.
|
||||
Unchanged,
|
||||
/// Rows `[common..]` of `new` are new; everything before `common` is
|
||||
/// byte-for-byte the same `FoldedRow` `old` already had.
|
||||
Appended { common: usize },
|
||||
/// Row `common` is the only one whose content differs; anything past
|
||||
/// it in `new` is a pure append after the replacement.
|
||||
ReplaceLast { common: usize },
|
||||
/// A row *before* the tail differs -- only `group_tool_runs` regrouping
|
||||
/// an earlier run does this, and nothing short of a full rebuild
|
||||
/// expresses it.
|
||||
Rebuild,
|
||||
}
|
||||
|
||||
fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff {
|
||||
let common = old
|
||||
.iter()
|
||||
.zip(new.iter())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
|
||||
if common == old.len() && common == new.len() {
|
||||
RowDiff::Unchanged
|
||||
} else if common == old.len() {
|
||||
RowDiff::Appended { common }
|
||||
} else if !old.is_empty() && common == old.len() - 1 && common < new.len() {
|
||||
// The `common < new.len()` guard is what tells "the tail row's
|
||||
// content changed" apart from "the tail row was removed and
|
||||
// nothing replaced it" (a shrinking list) -- the latter has
|
||||
// nothing at `new[common]` to rebuild into place.
|
||||
RowDiff::ReplaceLast { common }
|
||||
} else {
|
||||
RowDiff::Rebuild
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod diff_tests {
|
||||
use super::*;
|
||||
use client_core::transcript_fold::TranscriptItem;
|
||||
|
||||
fn user(seq: u64, text: &str) -> FoldedRow {
|
||||
FoldedRow::Single(TranscriptItem::UserMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn assistant(seq: u64, text: &str, settled: bool) -> FoldedRow {
|
||||
FoldedRow::Single(TranscriptItem::AssistantMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
settled,
|
||||
})
|
||||
}
|
||||
|
||||
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
|
||||
TranscriptItem::ToolRun {
|
||||
seq,
|
||||
id: format!("id{seq}"),
|
||||
run_id: run_id.to_string(),
|
||||
tool: "grep".to_string(),
|
||||
input: "x".to_string(),
|
||||
output: String::new(),
|
||||
done: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_lists_are_unchanged() {
|
||||
let rows = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||
assert_eq!(diff_rows(&rows, &rows.clone()), RowDiff::Unchanged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_list_growing_by_one_is_an_append_from_zero() {
|
||||
let old: Vec<FoldedRow> = Vec::new();
|
||||
let new = vec![user(1, "hi")];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_message_after_a_settled_reply_is_a_pure_append() {
|
||||
// The row that used to be the tail (a now-closed assistant
|
||||
// message) is unchanged; a new user message is appended after it
|
||||
// -- the transition every reply's *last* delta makes once the
|
||||
// next turn starts.
|
||||
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 2 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delta_into_the_open_reply_is_a_last_row_replace() {
|
||||
// The common streaming case: the assistant message's key (its
|
||||
// first delta's seq) never changes, only its text grows.
|
||||
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||
let new = vec![user(1, "hi"), assistant(2, "hello", false)];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delta_that_both_settles_the_reply_and_starts_the_next_row_is_still_a_replace() {
|
||||
// `ReplaceLast` only claims the row it names; `apply` appends
|
||||
// whatever comes after it separately -- this just confirms the
|
||||
// diff still recognises the replace even with a trailing append.
|
||||
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback() {
|
||||
// Two separate `Single` rows for the same run id become one
|
||||
// `Tools` row once `group_tool_runs` sees them adjacent -- that
|
||||
// changes row 0, not just the tail, so nothing short of a full
|
||||
// rebuild expresses it.
|
||||
let old = vec![FoldedRow::Single(tool(1, "run-a")), user(2, "meanwhile")];
|
||||
let new = vec![FoldedRow::Tools(vec![tool(1, "run-a"), tool(3, "run-a")])];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_the_list_is_a_rebuild() {
|
||||
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||
let new = vec![user(1, "hi")];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user