//! The transcript screen, in iris -- RUST.md's I5. Built the same way //! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` + //! `Rsc::State: FocusHost`, so the winit example (`iris/examples/ //! transcript.rs`) and an eventual `iris-android-app`-style cdylib call the //! same [`build`]. See RUST.md's I5 box for the full account of what is //! and is not proved yet, and this doc for the shape. //! //! ```text //! +------------------------------------------+ //! | iris::widget::List (transcript_ui::row) | <- .height(rest(1)) //! | row 1: sender label + one TextEdit | //! | row 2: sender label + one TextEdit | //! | row 3 (Tools): collapsed/expanded | //! | ... | //! +------------------------------------------+ //! | composer bar (transcript_ui::composer) | <- natural height //! +------------------------------------------+ //! ``` //! //! **What this crate does not do itself**: fetch anything over the network //! or read the transcript cache. [`build`] takes an already-folded //! `Vec` and //! [`TranscriptScreen::push_row`] takes one more as it arrives -- the //! caller (an app's own `main`, or a future `iris-android-app`-shaped //! cdylib) owns `client_core::ApiClient`/ //! `event_stream::follow_session_events` and the transcript cache, per the //! code rules' "ask for the least you need": a widget-tree builder that //! also knew how to make an HTTPS request would be untestable without a //! server and unable to be driven by `run-headless.sh` with synthetic rows. //! //! **Gap closed, 2026-09-05**: a touch-drag that starts on a row's //! rendered text used to always begin a cross-row *selection* (`row.rs`'s //! `CursorSense::click_or_drag()` on each row's `TextEdit`), never a //! *scroll* of the list, because both wanted the same gesture over the //! same screen region and `core/src/sense.rs`'s `run_sensors` gave the //! widget in the *inner* layer (a row's own `TextEdit`) first refusal //! every frame it was pressed. `row.rs` now routes every row's drag //! through one shared `iris::sense::DragArbiter` //! (`Selection::drag`, `selection.rs`), which decides pan vs. select the //! way Android itself does -- see `DragArbiter`'s own doc and //! `DECISIONS.md` for the exact rule. `List` scrolls correctly when //! driven programmatically (I3's benchmark), via the mouse wheel (wired //! below, `CursorSense::Scroll`), and now via a touch pan starting on a //! row's own text too. pub mod composer; pub mod markdown; pub mod row; pub mod selection; use client_core::transcript_fold::TranscriptRow as FoldedRow; use iris::prelude::*; use selection::Selection; use std::{cell::RefCell, rc::Rc, time::Instant}; pub struct TranscriptScreen { /// The transcript's own `List` -- exposed so a caller can read /// `.extent()`/call `.jump_to_end()` etc. directly for anything this /// crate does not already wrap. pub list: WeakWidget, pub composer: composer::Composer, selection: Rc>, /// 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, } impl TranscriptScreen { /// Append one more folded row at the live end of the transcript -- /// what a caller's SSE loop or a sent message calls as new events /// arrive. `List::push_back` is O(1) and keeps the view pinned to the /// newest content when it already was (I3). pub fn push_row(&self, rsc: &mut Rsc, row: &FoldedRow) where Rsc::State: FocusHost, { let (key, widget) = row::build_row(rsc, self.list, self.selection.clone(), row); (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( &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 { self.selection.borrow().selected_text(rsc) } } pub fn build( rsc: &mut Rsc, ui_state: &mut impl HasRoot, rows: Vec, ) -> TranscriptScreen where Rsc::State: FocusHost, { let (screen, tree) = build_tree(rsc, rows); ui_state.set_root(tree); screen } /// The same widget tree [`build`] makes, without claiming the window's /// whole root -- what a caller embedding this screen alongside something /// else of its own needs (RUST.md's E4: a session list beside the /// transcript on the desktop). `build` is `build_tree` plus /// `ui_state.set_root(tree)`; kept as its own function since most callers /// (the winit example, an eventual Android cdylib) want the screen to *be* /// the window and don't need the strong handle back. pub fn build_tree( rsc: &mut Rsc, rows: Vec, ) -> (TranscriptScreen, StrongWidget) where Rsc::State: FocusHost, { let selection = Rc::new(RefCell::new(Selection::new())); let list = List::new(Axis::Y).add(rsc); for row in &rows { let (key, widget) = row::build_row(rsc, list, selection.clone(), row); list(rsc).push_back(ListRow::new(key, widget)); } // Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s // `scrollable()` uses for `Scroll`, applied directly to `List` since // `List` already does its own placement and needs no `Scroll` wrapper. // Real touch-drag panning is the known gap in this module's doc. list.on(CursorSense::Scroll, |ctx, rsc| { let delta = ctx.data.scroll_delta.y * 50.0; ctx.widget(rsc).scroll(delta); }) .add(rsc); // The continuation of a row-started drag once it has committed and // taken pointer capture on `list`'s own id (`row.rs`'s registration is // only ever the gesture's first frame) -- registered once here, not // once per row, since `DragGesture`'s single shared instance must see // each frame of one gesture exactly once. `ctx.data.pos`/`size` are // already relative to `list`'s own on-screen box (this is what it was // registered against), which is exactly the viewport-pixel space // `List::key_at`/`extent` work in, so the row-under-the-pointer is // resolved from those instead of a per-row hit test. { let selection = selection.clone(); list.on( CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop, move |ctx, rsc| { let pos = ctx.data.pos; let row = list(rsc).key_at(pos.y).and_then(|key| { let (top, bottom) = list(rsc).extent(key)?; Some(( key, Vec2::new(pos.x, pos.y - top), Vec2::new(ctx.data.size.x, bottom - top), )) }); selection.borrow_mut().drag( rsc, list, row, ctx.data.cursor.pos, ctx.data.sense, Instant::now(), ctx.data.render, ); }, ) .add(rsc); } let (composer, composer_bar) = composer::build_composer(rsc); let tree = (list.width(rest(1)).height(rest(1)), composer_bar) .span(Dir::DOWN) .add_strong(rsc) .any(); ( TranscriptScreen { 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`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 = 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); } }