use super::older_history::OlderHistoryState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum TranscriptViewportMode {
#[default]
FollowBottom,
AnchoredTop {
top_row: usize,
reveal: Option<bool>,
},
TailSpace { top_row: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TranscriptViewport {
mode: TranscriptViewportMode,
offset: usize,
max_offset: usize,
bottom_overscroll: usize,
max_bottom_overscroll: usize,
previous_total_rows: usize,
viewport_height: u16,
}
impl TranscriptViewport {
#[must_use]
pub const fn allows_reveal(&self) -> bool {
matches!(
self.mode,
TranscriptViewportMode::FollowBottom
| TranscriptViewportMode::AnchoredTop {
reveal: Some(_),
..
}
)
}
#[must_use]
pub const fn allows_overflow(&self) -> bool {
matches!(
self.mode,
TranscriptViewportMode::FollowBottom
| TranscriptViewportMode::AnchoredTop {
reveal: Some(false),
..
}
)
}
pub fn reveal(&mut self, sticky: bool) {
let top_row = self.top_row(self.previous_total_rows, self.viewport_height);
self.mode = TranscriptViewportMode::AnchoredTop {
top_row,
reveal: Some(sticky),
};
}
pub const fn release_sticky_reveal(&mut self) {
if let TranscriptViewportMode::AnchoredTop { reveal, .. } = &mut self.mode
&& matches!(reveal, Some(true))
{
*reveal = Some(false);
}
}
pub fn detach(&mut self) {
if let TranscriptViewportMode::TailSpace { .. } = self.mode {
return;
}
let top_row = self.top_row(self.previous_total_rows, self.viewport_height);
self.mode = TranscriptViewportMode::AnchoredTop {
top_row,
reveal: None,
};
}
#[must_use]
#[cfg(test)]
pub const fn intent_name(&self) -> &'static str {
match self.mode {
TranscriptViewportMode::FollowBottom => "bottom_follow",
TranscriptViewportMode::AnchoredTop {
reveal: Some(_), ..
} => "anchored_to_entry",
_ => "manual_detached",
}
}
#[must_use]
pub const fn follows_bottom(&self) -> bool {
matches!(self.mode, TranscriptViewportMode::FollowBottom)
}
#[must_use]
pub const fn offset(&self) -> usize {
self.offset
}
#[must_use]
pub const fn bottom_overscroll(&self) -> usize {
self.bottom_overscroll
}
#[must_use]
pub const fn height(&self) -> u16 {
self.viewport_height
}
#[must_use]
pub const fn at_bottom_threshold(&self) -> bool {
matches!(self.mode, TranscriptViewportMode::FollowBottom) && self.bottom_overscroll == 0
}
#[must_use]
pub fn bottom_row(&self, total_rows: usize) -> usize {
self.top_row(total_rows, self.viewport_height)
.saturating_add(usize::from(self.viewport_height))
}
pub fn follow_anchor(&mut self, top_row: usize) {
self.mode = TranscriptViewportMode::AnchoredTop {
top_row: top_row.min(self.previous_total_rows),
reveal: None,
};
self.bottom_overscroll = 0;
self.refresh_offset_cache();
}
pub fn materialize_top_row(&mut self, top_row: usize) {
let reveal = match self.mode {
TranscriptViewportMode::AnchoredTop { reveal, .. } => reveal,
_ => None,
};
self.mode = TranscriptViewportMode::AnchoredTop {
top_row: top_row.min(self.previous_total_rows),
reveal,
};
self.bottom_overscroll = 0;
self.refresh_offset_cache();
}
#[must_use]
pub fn start_follow_anchor_animation(
&mut self,
target_top_row: usize,
) -> Option<(usize, usize)> {
let target_top_row = target_top_row.min(self.previous_total_rows);
let start_top_row = self.top_row(self.previous_total_rows, self.viewport_height);
if start_top_row == target_top_row {
self.materialize_top_row(target_top_row);
None
} else {
Some((start_top_row, target_top_row))
}
}
#[must_use]
pub fn top_row(&self, total_rows: usize, viewport_height: u16) -> usize {
let viewport_height = usize::from(viewport_height);
match self.mode {
TranscriptViewportMode::FollowBottom => total_rows
.saturating_add(self.bottom_overscroll)
.min(total_rows.saturating_add(self.max_bottom_overscroll))
.saturating_sub(viewport_height),
TranscriptViewportMode::AnchoredTop { top_row, .. }
| TranscriptViewportMode::TailSpace { top_row } => top_row.min(total_rows),
}
}
pub fn scroll_up(&mut self, rows: usize, older_history: &mut OlderHistoryState) -> bool {
if rows == 0 {
return false;
}
let previous = *self;
let current_top = self.top_row(self.previous_total_rows, self.viewport_height);
let new_top = current_top.saturating_sub(rows);
let unrevealed_rows = rows.saturating_sub(current_top);
let previous_request = older_history.reveal_request();
older_history.cancel_newer_reveal();
if unrevealed_rows > 0 {
request_older_history_load(older_history, unrevealed_rows);
}
self.mode = TranscriptViewportMode::AnchoredTop {
top_row: new_top,
reveal: None,
};
self.bottom_overscroll = 0;
self.refresh_offset_cache();
*self != previous || previous_request != older_history.reveal_request()
}
pub fn scroll_down(&mut self, rows: usize, history: &mut OlderHistoryState) -> bool {
if rows == 0 {
return false;
}
let previous = *self;
let viewport_height = usize::from(self.viewport_height);
history.cancel_older_reveal();
if history.has_newer_history() {
let current_top = self.top_row(self.previous_total_rows, self.viewport_height);
let boundary = self.previous_total_rows.saturating_sub(viewport_height);
let next = current_top.saturating_add(rows);
let previous_request = history.newer_reveal_request();
if next > boundary {
history.request_load_newer(next.saturating_sub(boundary));
}
self.follow_anchor(next.min(boundary));
return *self != previous || previous_request != history.newer_reveal_request();
}
match self.mode {
TranscriptViewportMode::FollowBottom | TranscriptViewportMode::TailSpace { .. } => {
self.bottom_overscroll = self
.bottom_overscroll
.saturating_add(rows)
.min(self.max_bottom_overscroll);
}
TranscriptViewportMode::AnchoredTop { .. } => {
let current_top = self.top_row(self.previous_total_rows, self.viewport_height);
let bottom_top = self.previous_total_rows.saturating_sub(viewport_height);
let next_top = current_top.saturating_add(rows);
if next_top >= bottom_top {
self.mode = TranscriptViewportMode::FollowBottom;
self.bottom_overscroll = next_top
.saturating_sub(bottom_top)
.min(self.max_bottom_overscroll);
} else {
self.mode = TranscriptViewportMode::AnchoredTop {
top_row: next_top,
reveal: None,
};
self.bottom_overscroll = 0;
}
}
}
if self.bottom_overscroll > 0 {
self.mode = TranscriptViewportMode::TailSpace {
top_row: self
.previous_total_rows
.saturating_add(self.bottom_overscroll)
.saturating_sub(viewport_height),
};
}
self.refresh_offset_cache();
*self != previous
}
pub const fn scroll_to_bottom(&mut self, older_history: &mut OlderHistoryState) -> bool {
let changed = !matches!(self.mode, TranscriptViewportMode::FollowBottom)
|| self.bottom_overscroll != 0;
self.mode = TranscriptViewportMode::FollowBottom;
self.offset = 0;
self.bottom_overscroll = 0;
older_history.clear_reveal_request();
changed
}
pub fn sync_max(
&mut self,
max_offset: usize,
max_bottom_overscroll: usize,
total_rows: usize,
viewport_height: u16,
_manual_scroll_active: bool,
_older_history: &mut OlderHistoryState,
) {
self.previous_total_rows = total_rows;
self.viewport_height = viewport_height;
self.max_offset = max_offset;
self.max_bottom_overscroll = max_bottom_overscroll;
self.resolve_tail_space();
self.clamp_anchor();
self.bottom_overscroll = self.bottom_overscroll.min(self.max_bottom_overscroll);
self.refresh_offset_cache();
}
pub fn sync_with_anchor(
&mut self,
bounds: (usize, usize, usize, u16),
anchor: Option<usize>,
older_page_rows: usize,
newer_page_rows: usize,
history: &mut OlderHistoryState,
) {
let previous_top = self.top_row(self.previous_total_rows, self.viewport_height);
let (older_ready, newer_ready, newer_has_content) = history.take_ready_pages();
if let Some(top_row) = anchor {
self.mode = match self.mode {
TranscriptViewportMode::FollowBottom => TranscriptViewportMode::FollowBottom,
TranscriptViewportMode::AnchoredTop { reveal, .. } => {
TranscriptViewportMode::AnchoredTop { top_row, reveal }
}
TranscriptViewportMode::TailSpace { .. } => {
TranscriptViewportMode::TailSpace { top_row }
}
};
}
self.sync_max(bounds.0, bounds.1, bounds.2, bounds.3, false, history);
if older_ready {
let requested = history.reveal_request().unwrap_or(0);
let top = self.top_row(bounds.2, bounds.3);
let movement = if anchor.is_some() {
requested
.min(top.saturating_sub(previous_top))
.min(older_page_rows)
} else {
0
};
if requested > 0 {
self.follow_anchor(top.saturating_sub(movement));
}
history.consume_reveal(true, movement);
}
if newer_ready {
let requested = history.newer_reveal_request().unwrap_or(0);
let top = self.top_row(bounds.2, bounds.3);
let available = bounds
.2
.saturating_sub(usize::from(bounds.3))
.saturating_sub(top);
let movement = if anchor.is_some() && newer_has_content {
requested.min(available).min(newer_page_rows)
} else {
0
};
if requested > 0 {
self.follow_anchor(top.saturating_add(movement));
}
history.consume_reveal(false, movement);
}
}
pub const fn reconcile_overflow(
&mut self,
previous_bottom: usize,
allowed: bool,
history: &mut OlderHistoryState,
) -> bool {
if allowed && self.previous_total_rows > previous_bottom {
return self.scroll_to_bottom(history);
}
false
}
#[cfg(test)]
pub fn restore_anchor(&mut self, top_row: usize) {
let top_row = top_row.min(self.previous_total_rows);
self.mode = match self.mode {
TranscriptViewportMode::FollowBottom => return,
TranscriptViewportMode::AnchoredTop { reveal, .. } => {
TranscriptViewportMode::AnchoredTop { top_row, reveal }
}
TranscriptViewportMode::TailSpace { .. } => {
TranscriptViewportMode::TailSpace { top_row }
}
};
self.resolve_tail_space();
self.refresh_offset_cache();
}
fn resolve_tail_space(&mut self) {
if let TranscriptViewportMode::TailSpace { top_row } = self.mode {
let top_row = top_row.min(self.previous_total_rows);
self.mode = TranscriptViewportMode::TailSpace { top_row };
let bottom = top_row.saturating_add(usize::from(self.viewport_height));
self.bottom_overscroll = bottom
.saturating_sub(self.previous_total_rows)
.min(self.max_bottom_overscroll);
if bottom <= self.previous_total_rows {
self.mode = TranscriptViewportMode::FollowBottom;
}
}
}
fn clamp_anchor(&mut self) {
if let TranscriptViewportMode::AnchoredTop { top_row, .. } = &mut self.mode {
*top_row = (*top_row).min(self.previous_total_rows);
}
}
fn refresh_offset_cache(&mut self) {
self.offset = match self.mode {
TranscriptViewportMode::FollowBottom | TranscriptViewportMode::TailSpace { .. } => 0,
TranscriptViewportMode::AnchoredTop { top_row, .. } => self
.previous_total_rows
.saturating_sub(top_row.saturating_add(usize::from(self.viewport_height)))
.min(self.max_offset),
};
}
}
fn request_older_history_load(older_history: &mut OlderHistoryState, reveal_rows: usize) {
if older_history.cursor().is_none() {
return;
}
older_history.request_load(reveal_rows.max(1));
}
#[cfg(test)]
mod tests {
use super::*;
fn older_history() -> OlderHistoryState {
OlderHistoryState::new(&[], false)
}
#[test]
fn zero_distance_animation_preserves_reveal_and_overflow_policy() {
for sticky in [false, true] {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
viewport.sync_max(20, 9, 30, 10, false, &mut history);
viewport.reveal(sticky);
let top = viewport.top_row(30, 10);
assert_eq!(viewport.start_follow_anchor_animation(top), None);
assert!(viewport.allows_reveal());
assert_eq!(viewport.allows_overflow(), !sticky);
viewport.sync_with_anchor((30, 9, 40, 10), None, 0, 0, &mut history);
let allowed = viewport.allows_overflow();
viewport.reconcile_overflow(30, allowed, &mut history);
assert_eq!(viewport.follows_bottom(), !sticky);
}
}
#[test]
fn exhausted_tail_space_reenables_reveal_policy() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
viewport.sync_max(20, 9, 30, 10, false, &mut history);
viewport.scroll_down(4, &mut history);
assert!(!viewport.allows_reveal());
viewport.sync_with_anchor((25, 9, 35, 10), None, 10, 0, &mut history);
assert!(viewport.follows_bottom());
assert!(viewport.allows_reveal());
}
#[test]
fn manual_input_removes_reveal_policy() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
viewport.sync_max(20, 9, 30, 10, false, &mut history);
viewport.reveal(true);
viewport.materialize_top_row(12);
assert!(viewport.allows_reveal());
assert!(!viewport.allows_overflow());
viewport.release_sticky_reveal();
assert!(viewport.allows_overflow());
viewport.scroll_up(1, &mut history);
assert!(!viewport.allows_reveal());
assert!(!viewport.allows_overflow());
}
#[test]
fn correspondence_precedes_tail_exhaustion() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
viewport.sync_max(20, 9, 30, 10, false, &mut history);
viewport.scroll_down(4, &mut history);
viewport.sync_with_anchor((30, 9, 40, 10), Some(34), 10, 0, &mut history);
assert!(!viewport.follows_bottom());
assert_eq!(viewport.bottom_overscroll(), 4);
assert_eq!(viewport.top_row(40, 10), 34);
}
#[test]
fn tail_space_restore_preserves_intent_and_derives_blank_rows() {
let mut viewport = TranscriptViewport::default();
let mut older = older_history();
viewport.sync_max(20, 9, 30, 10, false, &mut older);
viewport.scroll_down(4, &mut older);
viewport.sync_max(21, 9, 31, 10, false, &mut older);
viewport.restore_anchor(25);
assert_eq!(viewport.bottom_overscroll(), 4);
assert!(!viewport.follows_bottom());
assert_eq!(viewport.top_row(31, 10), 25);
}
#[test]
fn tail_space_does_not_ratchet_when_existing_geometry_oscillates() {
let mut viewport = TranscriptViewport::default();
let mut older = older_history();
viewport.sync_max(20, 9, 30, 10, false, &mut older);
viewport.scroll_down(4, &mut older);
let top = viewport.top_row(30, 10);
for _ in 0..20 {
viewport.sync_max(21, 9, 31, 10, false, &mut older);
assert_eq!(viewport.top_row(31, 10), top);
viewport.sync_max(20, 9, 30, 10, false, &mut older);
assert_eq!(viewport.top_row(30, 10), top);
assert_eq!(viewport.bottom_overscroll(), 4);
}
}
#[test]
fn tail_space_exhaustion_is_independent_of_manual_scroll_timing() {
for manual_scroll_active in [false, true] {
let mut viewport = TranscriptViewport::default();
let mut older = older_history();
viewport.sync_max(20, 9, 30, 10, false, &mut older);
viewport.scroll_down(4, &mut older);
assert!(!viewport.follows_bottom());
viewport.sync_max(22, 9, 32, 10, manual_scroll_active, &mut older);
assert_eq!(viewport.top_row(32, 10), 24);
assert_eq!(viewport.bottom_overscroll(), 2);
viewport.sync_max(25, 9, 35, 10, manual_scroll_active, &mut older);
assert!(viewport.follows_bottom());
assert_eq!(viewport.top_row(35, 10), 25);
}
}
#[test]
fn tail_space_repeated_measurement_is_idempotent() {
let mut viewport = TranscriptViewport::default();
let mut older = older_history();
viewport.sync_max(20, 9, 30, 10, false, &mut older);
viewport.scroll_down(4, &mut older);
viewport.sync_max(21, 9, 31, 10, false, &mut older);
let resolved = viewport;
viewport.sync_max(21, 9, 31, 10, false, &mut older);
assert_eq!(viewport, resolved);
}
#[test]
fn anchored_history_top_row_does_not_move_when_rows_append() {
let mut viewport = TranscriptViewport::default();
let mut older = older_history();
viewport.sync_max(20, 0, 30, 10, false, &mut older);
viewport.scroll_up(8, &mut older);
let top_row = viewport.top_row(30, 10);
viewport.sync_max(25, 0, 35, 10, false, &mut older);
assert_eq!(viewport.top_row(35, 10), top_row);
}
#[test]
fn anchored_history_top_row_survives_async_row_height_growth() {
let mut viewport = TranscriptViewport::default();
let mut older = older_history();
viewport.sync_max(30, 0, 40, 10, false, &mut older);
viewport.scroll_up(12, &mut older);
let top_row = viewport.top_row(40, 10);
viewport.sync_max(37, 0, 47, 10, false, &mut older);
assert_eq!(viewport.top_row(47, 10), top_row);
assert_eq!(viewport.offset(), 47 - top_row - 10);
}
#[test]
fn following_bottom_tracks_async_row_height_growth() {
let mut viewport = TranscriptViewport::default();
let mut older = older_history();
viewport.sync_max(30, 0, 40, 10, false, &mut older);
viewport.sync_max(37, 0, 47, 10, false, &mut older);
assert_eq!(viewport.top_row(47, 10), 37);
assert!(viewport.at_bottom_threshold());
}
#[test]
fn following_bottom_tracks_appended_rows() {
let mut viewport = TranscriptViewport::default();
let mut older = older_history();
viewport.sync_max(20, 0, 30, 10, false, &mut older);
assert_eq!(viewport.top_row(30, 10), 20);
viewport.sync_max(25, 0, 35, 10, false, &mut older);
assert_eq!(viewport.top_row(35, 10), 25);
assert_eq!(viewport.offset(), 0);
assert!(viewport.at_bottom_threshold());
}
#[test]
fn anchored_history_top_row_does_not_move_when_viewport_shrinks() {
let mut viewport = TranscriptViewport::default();
let mut older = older_history();
viewport.sync_max(20, 0, 30, 10, false, &mut older);
viewport.scroll_up(8, &mut older);
let top_row = viewport.top_row(30, 10);
viewport.sync_max(21, 0, 30, 9, false, &mut older);
assert_eq!(viewport.top_row(30, 9), top_row);
}
#[test]
fn successive_older_pages_reveal_only_outstanding_boundary_movement() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
history.mark_dropped_history_before(30);
viewport.sync_max(20, 0, 30, 10, false, &mut history);
viewport.follow_anchor(0);
viewport.scroll_up(9, &mut history);
viewport.scroll_up(4, &mut history);
assert_eq!(history.reveal_request(), Some(13));
history.set_loading(true);
let session_id = bcode_session_models::SessionId::new();
let event = |sequence| bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "older".to_owned(),
},
};
history.update_cursor(&[event(20)], true);
history.set_loading(false);
viewport.sync_with_anchor((25, 0, 35, 10), Some(5), 10, 0, &mut history);
assert_eq!(viewport.top_row(35, 10), 0);
assert_eq!(history.reveal_request(), Some(8));
viewport.sync_with_anchor((25, 0, 35, 10), Some(5), 10, 0, &mut history);
assert_eq!(history.reveal_request(), Some(8));
assert!(history.should_load());
history.set_loading(true);
history.update_cursor(&[], false);
history.set_loading(false);
viewport.sync_with_anchor((38, 0, 48, 10), Some(18), 10, 0, &mut history);
assert_eq!(viewport.top_row(48, 10), 10);
assert_eq!(history.reveal_request(), None);
viewport.sync_with_anchor((38, 0, 48, 10), Some(10), 10, 0, &mut history);
assert_eq!(viewport.top_row(48, 10), 10);
}
#[test]
fn scrolling_to_centered_newer_boundary_does_not_request_an_extra_row() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
let session_id = bcode_session_models::SessionId::new();
let event = bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 10,
timestamp_ms: 10,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "resident".to_owned(),
},
};
history.replace_centered(std::slice::from_ref(&event), false, true, 10);
viewport.sync_max(20, 0, 30, 10, false, &mut history);
viewport.follow_anchor(15);
assert!(viewport.scroll_down(5, &mut history));
assert_eq!(viewport.top_row(30, 10), 20);
assert_eq!(history.newer_reveal_request(), None);
assert!(!history.should_load_newer());
assert!(viewport.scroll_down(1, &mut history));
assert_eq!(history.newer_reveal_request(), Some(1));
assert!(history.should_load_newer());
}
#[test]
fn exhausted_newer_page_does_not_spend_pending_movement_on_unrelated_growth() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
let session_id = bcode_session_models::SessionId::new();
let event = bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 10,
timestamp_ms: 10,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "resident".to_owned(),
},
};
history.replace_centered(std::slice::from_ref(&event), false, true, 10);
viewport.sync_max(20, 0, 30, 10, false, &mut history);
viewport.follow_anchor(15);
viewport.scroll_down(8, &mut history);
assert_eq!(history.newer_reveal_request(), Some(3));
history.set_loading_newer(true);
history.update_newer_cursor(&[], false);
history.set_loading_newer(false);
viewport.sync_with_anchor((30, 0, 40, 10), Some(20), 10, 0, &mut history);
assert_eq!(viewport.top_row(40, 10), 20);
assert_eq!(history.newer_reveal_request(), None);
viewport.sync_with_anchor((30, 0, 40, 10), Some(20), 10, 0, &mut history);
assert_eq!(viewport.top_row(40, 10), 20);
}
#[test]
fn terminal_newer_page_can_reveal_its_own_rows_once() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
let session_id = bcode_session_models::SessionId::new();
let event = bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 10,
timestamp_ms: 10,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "resident".to_owned(),
},
};
history.replace_centered(std::slice::from_ref(&event), false, true, 10);
viewport.sync_max(20, 0, 30, 10, false, &mut history);
viewport.follow_anchor(15);
viewport.scroll_down(8, &mut history);
assert_eq!(history.newer_reveal_request(), Some(3));
history.set_loading_newer(true);
history.update_newer_cursor(
&[bcode_session_models::SessionEvent {
sequence: 11,
..event
}],
false,
);
history.set_loading_newer(false);
viewport.sync_with_anchor((30, 0, 40, 10), Some(20), 0, 10, &mut history);
assert_eq!(viewport.top_row(40, 10), 23);
assert_eq!(history.newer_reveal_request(), None);
viewport.sync_with_anchor((30, 0, 40, 10), Some(23), 10, 0, &mut history);
assert_eq!(viewport.top_row(40, 10), 23);
}
#[test]
fn newer_page_only_spends_measured_page_rows_during_concurrent_reflow() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
let session_id = bcode_session_models::SessionId::new();
let event = bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 10,
timestamp_ms: 10,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "resident".to_owned(),
},
};
history.replace_centered(std::slice::from_ref(&event), false, true, 10);
viewport.sync_max(20, 0, 30, 10, false, &mut history);
viewport.follow_anchor(15);
viewport.scroll_down(8, &mut history);
assert_eq!(history.newer_reveal_request(), Some(3));
history.set_loading_newer(true);
history.update_newer_cursor(
&[bcode_session_models::SessionEvent {
sequence: 11,
..event
}],
true,
);
history.set_loading_newer(false);
viewport.sync_with_anchor((35, 0, 45, 10), Some(25), 0, 2, &mut history);
assert_eq!(viewport.top_row(45, 10), 27);
assert_eq!(history.newer_reveal_request(), Some(1));
viewport.sync_with_anchor((35, 0, 45, 10), Some(27), 0, 2, &mut history);
assert_eq!(viewport.top_row(45, 10), 27);
assert_eq!(history.newer_reveal_request(), Some(1));
}
#[test]
fn newer_page_without_new_visible_rows_keeps_requested_movement_pending() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
let session_id = bcode_session_models::SessionId::new();
let event = bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 10,
timestamp_ms: 10,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "resident".to_owned(),
},
};
history.replace_centered(std::slice::from_ref(&event), false, true, 10);
viewport.sync_max(20, 0, 30, 10, false, &mut history);
viewport.follow_anchor(15);
viewport.scroll_down(6, &mut history);
assert_eq!(history.newer_reveal_request(), Some(1));
history.set_loading_newer(true);
history.update_newer_cursor(
&[bcode_session_models::SessionEvent {
sequence: 11,
..event
}],
true,
);
history.set_loading_newer(false);
viewport.sync_with_anchor((20, 0, 30, 10), Some(20), 10, 0, &mut history);
assert_eq!(viewport.top_row(30, 10), 20);
assert_eq!(history.newer_reveal_request(), Some(1));
viewport.sync_with_anchor((20, 0, 30, 10), Some(20), 10, 0, &mut history);
assert_eq!(viewport.top_row(30, 10), 20);
assert_eq!(history.newer_reveal_request(), Some(1));
}
#[test]
fn older_page_without_new_visible_rows_does_not_reveal_existing_content() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
history.mark_dropped_history_before(10);
viewport.sync_max(20, 0, 30, 10, false, &mut history);
viewport.follow_anchor(0);
viewport.scroll_up(3, &mut history);
history.set_loading(true);
history.update_cursor(&[], true);
history.mark_dropped_history_before(9);
history.set_loading(false);
viewport.sync_with_anchor((20, 0, 30, 10), Some(0), 10, 0, &mut history);
assert_eq!(viewport.top_row(30, 10), 0);
assert_eq!(history.reveal_request(), Some(3));
}
#[test]
fn older_page_does_not_spend_reflow_above_reader_as_page_rows() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
history.mark_dropped_history_before(10);
viewport.sync_max(20, 0, 30, 10, false, &mut history);
viewport.follow_anchor(5);
viewport.scroll_up(9, &mut history);
assert_eq!(history.reveal_request(), Some(4));
history.update_cursor(&[], true);
history.mark_dropped_history_before(9);
viewport.sync_with_anchor((30, 0, 40, 10), Some(10), 2, 0, &mut history);
assert_eq!(viewport.top_row(40, 10), 8);
assert_eq!(history.reveal_request(), Some(2));
viewport.sync_with_anchor((30, 0, 40, 10), Some(8), 0, 0, &mut history);
assert_eq!(history.reveal_request(), Some(2));
}
#[test]
fn older_page_does_not_count_rows_added_below_reader() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
history.mark_dropped_history_before(10);
viewport.sync_max(20, 0, 30, 10, false, &mut history);
viewport.follow_anchor(0);
viewport.scroll_up(3, &mut history);
history.set_loading(true);
history.update_cursor(&[], true);
history.mark_dropped_history_before(9);
history.set_loading(false);
viewport.sync_with_anchor((40, 0, 50, 10), Some(2), 10, 0, &mut history);
assert_eq!(viewport.top_row(50, 10), 0);
assert_eq!(history.reveal_request(), Some(1));
}
#[test]
fn missing_item_anchor_does_not_treat_growth_below_as_prepend() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
history.mark_dropped_history_before(10);
viewport.sync_max(20, 0, 30, 10, false, &mut history);
viewport.follow_anchor(0); viewport.scroll_up(3, &mut history);
history.set_loading(true);
history.update_cursor(&[], true);
history.mark_dropped_history_before(9);
history.set_loading(false);
viewport.sync_with_anchor((40, 0, 50, 10), None, 10, 0, &mut history);
assert_eq!(viewport.top_row(50, 10), 0);
assert_eq!(history.reveal_request(), Some(3));
}
#[test]
fn boundary_scroll_applies_available_rows_and_retains_loading_movement() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
history.mark_dropped_history_before(10);
viewport.sync_max(20, 9, 30, 10, false, &mut history);
viewport.follow_anchor(2);
assert!(viewport.scroll_up(3, &mut history));
assert_eq!(viewport.top_row(30, 10), 0);
assert_eq!(history.reveal_request(), Some(1));
history.set_loading(true);
viewport.scroll_up(2, &mut history);
viewport.sync_with_anchor((20, 9, 30, 10), Some(0), 10, 0, &mut history);
assert_eq!(history.reveal_request(), Some(3));
history.update_cursor(&[], false);
history.set_loading(false);
viewport.sync_with_anchor((30, 9, 40, 10), Some(10), 10, 0, &mut history);
assert_eq!(viewport.top_row(40, 10), 7);
assert_eq!(history.reveal_request(), None);
}
#[test]
fn direction_reversal_cancels_pending_history_movement() {
let mut viewport = TranscriptViewport::default();
let mut history = older_history();
history.mark_dropped_history_before(10);
viewport.sync_max(20, 9, 30, 10, false, &mut history);
viewport.follow_anchor(0);
viewport.scroll_up(3, &mut history);
viewport.scroll_down(2, &mut history);
history.update_cursor(&[], false);
viewport.sync_with_anchor((30, 9, 40, 10), Some(12), 10, 0, &mut history);
assert_eq!(viewport.top_row(40, 10), 12);
}
#[test]
fn older_history_reveal_keeps_same_content_visible_after_prepend() {
let mut viewport = TranscriptViewport::default();
let mut older = older_history();
viewport.sync_max(20, 0, 30, 10, false, &mut older);
viewport.scroll_up(8, &mut older);
older.update_cursor(&[], false);
viewport.sync_with_anchor((24, 0, 34, 10), Some(16), 10, 0, &mut older);
assert_eq!(viewport.top_row(34, 10), 16);
}
}