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
|
||||
//! 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