Keep chat scrolling under user control

This commit is contained in:
Georg Bauer
2026-07-27 18:58:45 +02:00
parent 7f14b6008a
commit 22a939751e
3 changed files with 50 additions and 8 deletions

View File

@@ -83,6 +83,8 @@ pub(crate) struct App {
pub(super) composer: String,
pub(super) queued_inputs: VecDeque<String>,
pub(super) conversation: Vec<ChatMessage>,
/// Follow appended chat content until the user scrolls away from the tail.
pub(super) chat_follow_tail: bool,
active_turn: Option<generation::TurnSummary>,
pub(super) a2ui: crate::a2ui::Store,
pub(super) a2ui_history: Vec<crate::a2ui::Store>,
@@ -310,6 +312,7 @@ pub(crate) enum Message {
SubmitPrompt,
StopGeneration,
GenerationTick,
ChatScrolled(scrollable::Viewport),
ChooseProjectFolder,
ProjectFolderPicked(Option<PathBuf>),
ProjectNameChanged(String),
@@ -407,6 +410,7 @@ impl App {
composer: String::new(),
queued_inputs: VecDeque::new(),
conversation: Vec::new(),
chat_follow_tail: true,
active_turn: None,
a2ui: crate::a2ui::Store::default(),
a2ui_history: Vec::new(),
@@ -535,6 +539,7 @@ impl App {
composer: String::new(),
queued_inputs: VecDeque::new(),
conversation: Vec::new(),
chat_follow_tail: true,
active_turn: None,
a2ui: crate::a2ui::Store::default(),
a2ui_history: Vec::new(),
@@ -729,6 +734,7 @@ impl App {
}
Message::ShowChat => {
self.detail_tab = DetailTab::Chat;
self.chat_follow_tail = true;
return scroll_chat_to_end();
}
Message::ShowA2ui => self.detail_tab = DetailTab::A2ui,
@@ -1069,6 +1075,7 @@ impl App {
serde_json::to_string(&action).unwrap_or_default(),
self.a2ui.client_metadata()
);
self.chat_follow_tail = true;
self.start_generation();
return scroll_chat_to_end();
}
@@ -1204,6 +1211,7 @@ impl App {
}
}
Message::SubmitPrompt => {
self.chat_follow_tail = true;
self.start_generation();
return scroll_chat_to_end();
}
@@ -1236,13 +1244,19 @@ impl App {
Message::GenerationTick => {
#[cfg(target_os = "macos")]
self.poll_titling();
let changed = self.poll_generation();
self.poll_generation();
let images = self.load_next_a2ui_image();
if changed {
return Task::batch([scroll_chat_to_end(), images]);
}
return images;
}
Message::ChatScrolled(viewport) => {
let offset = viewport.absolute_offset();
let reversed = viewport.absolute_offset_reversed();
let follow = chat_near_end(self.chat_follow_tail, offset.y, reversed.y);
if follow != self.chat_follow_tail {
self.chat_follow_tail = follow;
return iced::widget::operation::scroll_to(chat_scroll_id(), reversed);
}
}
Message::ChooseProjectFolder => {
self.choosing_folder = true;
return Task::perform(
@@ -1486,6 +1500,7 @@ impl App {
return Task::none();
}
self.conversation = messages.into_iter().map(ChatMessage::from).collect();
self.chat_follow_tail = true;
generation::promote_legacy_turn_summaries(&mut self.conversation);
self.context_notice = None;
self.clear_a2ui();
@@ -2121,7 +2136,19 @@ fn focus_composer() -> Task<Message> {
}
fn scroll_chat_to_end() -> Task<Message> {
iced::widget::operation::snap_to(chat_scroll_id(), scrollable::RelativeOffset::END)
iced::widget::operation::scroll_to(
chat_scroll_id(),
scrollable::AbsoluteOffset::<f32>::default(),
)
}
fn chat_near_end(following: bool, offset: f32, reversed_offset: f32) -> bool {
const TAIL_THRESHOLD: f32 = 24.0;
if following {
offset <= TAIL_THRESHOLD
} else {
reversed_offset <= TAIL_THRESHOLD
}
}
/// Deletes session checkpoints whose session is gone. Deleting a session or a
@@ -2255,6 +2282,14 @@ mod tests {
assert_eq!(section_offset(field(990.0), viewport, 500.0), 390.0);
}
#[test]
fn chat_only_follows_updates_while_near_the_tail() {
assert!(chat_near_end(true, 0.0, 500.0));
assert!(!chat_near_end(true, 25.0, 475.0));
assert!(chat_near_end(false, 500.0, 24.0));
assert!(!chat_near_end(false, 400.0, 100.0));
}
#[test]
fn ds4_gib_and_streaming_cache_inputs_are_typed() {
assert_eq!(