iris: streaming a transcript event no longer rebuilds the whole screen
Every client (bench_client, transcript_client, desktop-app) refolded and rebuilt the ~3,200-row widget tree from scratch per SSE event, which is the streaming-phase cost the P0 benchmark gate would otherwise measure against a Compose app that updates one row. iris::widget::List gains replace_back (swap the last row's widget in place, keeping its slot so a pinned list stays pinned) and clear (the full-rebuild fallback); transcript_ui::TranscriptScreen::apply diffs the folded row lists and picks the cheapest update -- unchanged, append, replace-the-last-row, or (rare regroup) a full rebuild, counted. TextEditCtx::set_with_spans lets a row's text and span list land together on a streamed update. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
50fe4828a2
commit
b3b1d47dd6
6 files changed
+460
-39
No files matched your search
@@ -4,17 +4,21 @@
|
|||||||
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
||||||
//! that measures the renderer rather than the data or the network.
|
//! that measures the renderer rather than the data or the network.
|
||||||
//!
|
//!
|
||||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, a full
|
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
||||||
//! `transcript_ui::build_tree` rebuild per event) with the network half
|
//! `TranscriptScreen::apply` incremental update on every event) with the
|
||||||
//! replaced by the checked-in fixture, embedded with `include_str!` --
|
//! network half replaced by the checked-in fixture, embedded with
|
||||||
//! `app/bench-fixture/assets/transcript.jsonl`, 1,915,760 bytes, generated
|
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`,
|
||||||
//! by `app/bench-fixture/generate.py` and never a real transcript (that
|
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never
|
||||||
//! file's own README). The first 3,200 lines are the opening backlog,
|
//! a real transcript (that file's own README). The first 3,200 lines are
|
||||||
//! folded once through `client_core::transcript_fold::fold_page` exactly
|
//! the opening backlog, folded once through
|
||||||
//! as a real `/transcript` page would be; the remaining ~400 are the
|
//! `client_core::transcript_fold::fold_page` exactly as a real
|
||||||
//! streaming tail, replayed one at a time through `fold_event` -- the same
|
//! `/transcript` page would be (then a full `transcript_ui::build_tree`,
|
||||||
//! fold path a live SSE reply arrives on -- by the "Run benchmark"
|
//! same as any first load); the remaining ~400 are the streaming tail,
|
||||||
//! control below.
|
//! 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 crate::bench_jni::PlatformHandle;
|
||||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||||
@@ -344,8 +348,15 @@ impl BenchClient {
|
|||||||
let mut sent = 0usize;
|
let mut sent = 0usize;
|
||||||
for event in stream_tail.into_iter().take(total) {
|
for event in stream_tail.into_iter().take(total) {
|
||||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
let old_items = state.items.clone();
|
||||||
state.items = fold_event(&state.items, &event);
|
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();
|
redraw.request_redraw();
|
||||||
sent += 1;
|
sent += 1;
|
||||||
|
|||||||
@@ -20,14 +20,22 @@
|
|||||||
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
||||||
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
||||||
//! `client_core::transcript_fold`, a `generation` counter guarding against
|
//! `client_core::transcript_fold`, a `generation` counter guarding against
|
||||||
//! a stale background response, and a full rebuild of the widget tree on
|
//! a stale background response. What differs is only the redraw
|
||||||
//! every event (same tradeoff, same reason: `push_row` cannot update a row
|
//! mechanism: android-view has no `winit::EventLoopProxy`, so this uses
|
||||||
//! already on screen, and this rig's conversations are small). What
|
//! `iris::task::Tasks::redraw_handle` (new, added alongside this box) to
|
||||||
//! differs is only the redraw mechanism: android-view has no
|
//! request a frame after each `TaskCtx::update` instead of relying on
|
||||||
//! `winit::EventLoopProxy`, so this uses `iris::task::Tasks::redraw_handle`
|
//! `Tasks::spawn`'s single end-of-future redraw -- see that method's own
|
||||||
//! (new, added alongside this box) to request a frame after each
|
//! doc for why.
|
||||||
//! `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::api::{ApiClient, UreqTransport};
|
||||||
use client_core::event_stream::{StreamItem, follow_session_events};
|
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) {
|
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.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) {
|
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
|
//! **Incoming SSE events go through `TranscriptScreen::apply`**, not a
|
||||||
//! the *entire* transcript (`client_core::transcript_fold::fold_event` is
|
//! full rebuild: `client_core::transcript_fold::fold_event` folds the new
|
||||||
//! already `O(items)` and a desktop session's conversation is small) and
|
//! item list as before, then `apply` updates only the row(s) that actually
|
||||||
//! rebuilds the whole right-hand widget tree from scratch, rather than
|
//! changed (almost always the one still-open assistant message a delta
|
||||||
//! reaching for `TranscriptScreen::push_row`'s incremental append.
|
//! landed in) instead of rebuilding the whole right-hand widget tree from
|
||||||
//! `push_row` cannot update a row already on screen -- only append a new
|
//! scratch. `rebuild_transcript` still runs the whole tree once, for a
|
||||||
//! one -- and a streaming assistant reply is exactly a row whose *text*
|
//! freshly loaded/selected session and for `apply`'s own rare
|
||||||
//! keeps changing after it first appears (see `transcript-ui`'s own doc on
|
//! full-rebuild fallback (a `group_tool_runs` regroup touching a row
|
||||||
//! `fold_event` folding deltas into one growing item). A full rebuild
|
//! before the tail). The composer's in-progress text survives a rebuild
|
||||||
//! shows that growth correctly at the cost of redrawing everything each
|
//! (`rebuild_transcript`'s `in_progress` local) since the user typing a
|
||||||
//! time; fine for this proof, wrong for a long, fast-streaming transcript
|
//! followup while a reply streams in is the one case a naive rebuild
|
||||||
//! -- the incremental path that fixes it needs `transcript-ui` to expose
|
//! would otherwise lose data on -- `apply`'s own path never touches the
|
||||||
//! updating a row in place, which it does not yet. The composer's
|
//! composer at all, so this only matters on the fallback.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! Background network I/O (`client_core::api`/`event_stream`, both
|
//! Background network I/O (`client_core::api`/`event_stream`, both
|
||||||
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
|
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
|
||||||
@@ -209,8 +205,12 @@ impl DefaultAppState for Client {
|
|||||||
event,
|
event,
|
||||||
} => {
|
} => {
|
||||||
if self.current(&session_id, generation) {
|
if self.current(&session_id, generation) {
|
||||||
|
let old_items = self.items.clone();
|
||||||
self.items = fold_event(&self.items, &event);
|
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 {
|
AppEvent::StreamEnded {
|
||||||
|
|||||||
@@ -317,6 +317,40 @@ impl List {
|
|||||||
self.extents.clear();
|
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
|
/// Move the anchor's edge by `amt` pixels. Positive moves later
|
||||||
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
||||||
/// Deliberately unclamped -- see the module doc's "what is not
|
/// 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}");
|
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;
|
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) {
|
pub fn motion(&mut self, motion: Motion, select: bool) {
|
||||||
let Some(sel) = self.text.selection else {
|
let Some(sel) = self.text.selection else {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ pub struct TranscriptScreen {
|
|||||||
pub list: WeakWidget<List>,
|
pub list: WeakWidget<List>,
|
||||||
pub composer: composer::Composer,
|
pub composer: composer::Composer,
|
||||||
selection: Rc<RefCell<Selection>>,
|
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 {
|
impl TranscriptScreen {
|
||||||
@@ -75,6 +81,93 @@ impl TranscriptScreen {
|
|||||||
(self.list)(rsc).push_back(ListRow::new(key, widget));
|
(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
|
/// The concatenated text of whatever is currently selected across one
|
||||||
/// or more rows, `None` if nothing is -- what a copy command reads.
|
/// or more rows, `None` if nothing is -- what a copy command reads.
|
||||||
pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option<String> {
|
pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option<String> {
|
||||||
@@ -139,7 +232,148 @@ where
|
|||||||
list,
|
list,
|
||||||
composer,
|
composer,
|
||||||
selection,
|
selection,
|
||||||
|
rebuilds: std::cell::Cell::new(0),
|
||||||
},
|
},
|
||||||
tree,
|
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