From 22a939751ed535fed4b8f637e166d8783bca2ef1 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Mon, 27 Jul 2026 18:58:45 +0200 Subject: [PATCH] Keep chat scrolling under user control --- src/app.rs | 45 +++++++++++++++++++++++++++++++++++++++----- src/app/projects.rs | 2 ++ src/app/view/chat.rs | 11 ++++++++--- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/app.rs b/src/app.rs index c33bedb..529d3af 100644 --- a/src/app.rs +++ b/src/app.rs @@ -83,6 +83,8 @@ pub(crate) struct App { pub(super) composer: String, pub(super) queued_inputs: VecDeque, pub(super) conversation: Vec, + /// Follow appended chat content until the user scrolls away from the tail. + pub(super) chat_follow_tail: bool, active_turn: Option, pub(super) a2ui: crate::a2ui::Store, pub(super) a2ui_history: Vec, @@ -310,6 +312,7 @@ pub(crate) enum Message { SubmitPrompt, StopGeneration, GenerationTick, + ChatScrolled(scrollable::Viewport), ChooseProjectFolder, ProjectFolderPicked(Option), 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 { } fn scroll_chat_to_end() -> Task { - iced::widget::operation::snap_to(chat_scroll_id(), scrollable::RelativeOffset::END) + iced::widget::operation::scroll_to( + chat_scroll_id(), + scrollable::AbsoluteOffset::::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!( diff --git a/src/app/projects.rs b/src/app/projects.rs index db02342..e03f8e6 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -175,6 +175,7 @@ impl App { self.remember_project(project_id); self.selected_session = None; self.conversation.clear(); + self.chat_follow_tail = true; self.context_notice = None; self.clear_a2ui(); self.composer.clear(); @@ -189,6 +190,7 @@ impl App { pub(super) fn discard_session(&mut self, project_id: i32) { if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) { self.conversation.clear(); + self.chat_follow_tail = true; self.context_notice = None; self.clear_a2ui(); self.composer.clear(); diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index b680a51..67422c3 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -315,9 +315,14 @@ impl App { .style(preference_group_style), ); } - let transcript = scrollable(messages) - .id(chat_scroll_id()) - .height(Length::Fill); + let transcript = if self.chat_follow_tail { + scrollable(messages).anchor_bottom() + } else { + scrollable(messages) + } + .id(chat_scroll_id()) + .on_scroll(Message::ChatScrolled) + .height(Length::Fill); let conversation = column![ transcript, container(composer_content,)