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) composer: String,
pub(super) queued_inputs: VecDeque<String>, pub(super) queued_inputs: VecDeque<String>,
pub(super) conversation: Vec<ChatMessage>, 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>, active_turn: Option<generation::TurnSummary>,
pub(super) a2ui: crate::a2ui::Store, pub(super) a2ui: crate::a2ui::Store,
pub(super) a2ui_history: Vec<crate::a2ui::Store>, pub(super) a2ui_history: Vec<crate::a2ui::Store>,
@@ -310,6 +312,7 @@ pub(crate) enum Message {
SubmitPrompt, SubmitPrompt,
StopGeneration, StopGeneration,
GenerationTick, GenerationTick,
ChatScrolled(scrollable::Viewport),
ChooseProjectFolder, ChooseProjectFolder,
ProjectFolderPicked(Option<PathBuf>), ProjectFolderPicked(Option<PathBuf>),
ProjectNameChanged(String), ProjectNameChanged(String),
@@ -407,6 +410,7 @@ impl App {
composer: String::new(), composer: String::new(),
queued_inputs: VecDeque::new(), queued_inputs: VecDeque::new(),
conversation: Vec::new(), conversation: Vec::new(),
chat_follow_tail: true,
active_turn: None, active_turn: None,
a2ui: crate::a2ui::Store::default(), a2ui: crate::a2ui::Store::default(),
a2ui_history: Vec::new(), a2ui_history: Vec::new(),
@@ -535,6 +539,7 @@ impl App {
composer: String::new(), composer: String::new(),
queued_inputs: VecDeque::new(), queued_inputs: VecDeque::new(),
conversation: Vec::new(), conversation: Vec::new(),
chat_follow_tail: true,
active_turn: None, active_turn: None,
a2ui: crate::a2ui::Store::default(), a2ui: crate::a2ui::Store::default(),
a2ui_history: Vec::new(), a2ui_history: Vec::new(),
@@ -729,6 +734,7 @@ impl App {
} }
Message::ShowChat => { Message::ShowChat => {
self.detail_tab = DetailTab::Chat; self.detail_tab = DetailTab::Chat;
self.chat_follow_tail = true;
return scroll_chat_to_end(); return scroll_chat_to_end();
} }
Message::ShowA2ui => self.detail_tab = DetailTab::A2ui, Message::ShowA2ui => self.detail_tab = DetailTab::A2ui,
@@ -1069,6 +1075,7 @@ impl App {
serde_json::to_string(&action).unwrap_or_default(), serde_json::to_string(&action).unwrap_or_default(),
self.a2ui.client_metadata() self.a2ui.client_metadata()
); );
self.chat_follow_tail = true;
self.start_generation(); self.start_generation();
return scroll_chat_to_end(); return scroll_chat_to_end();
} }
@@ -1204,6 +1211,7 @@ impl App {
} }
} }
Message::SubmitPrompt => { Message::SubmitPrompt => {
self.chat_follow_tail = true;
self.start_generation(); self.start_generation();
return scroll_chat_to_end(); return scroll_chat_to_end();
} }
@@ -1236,13 +1244,19 @@ impl App {
Message::GenerationTick => { Message::GenerationTick => {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
self.poll_titling(); self.poll_titling();
let changed = self.poll_generation(); self.poll_generation();
let images = self.load_next_a2ui_image(); let images = self.load_next_a2ui_image();
if changed {
return Task::batch([scroll_chat_to_end(), images]);
}
return 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 => { Message::ChooseProjectFolder => {
self.choosing_folder = true; self.choosing_folder = true;
return Task::perform( return Task::perform(
@@ -1486,6 +1500,7 @@ impl App {
return Task::none(); return Task::none();
} }
self.conversation = messages.into_iter().map(ChatMessage::from).collect(); self.conversation = messages.into_iter().map(ChatMessage::from).collect();
self.chat_follow_tail = true;
generation::promote_legacy_turn_summaries(&mut self.conversation); generation::promote_legacy_turn_summaries(&mut self.conversation);
self.context_notice = None; self.context_notice = None;
self.clear_a2ui(); self.clear_a2ui();
@@ -2121,7 +2136,19 @@ fn focus_composer() -> Task<Message> {
} }
fn scroll_chat_to_end() -> 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 /// 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); 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] #[test]
fn ds4_gib_and_streaming_cache_inputs_are_typed() { fn ds4_gib_and_streaming_cache_inputs_are_typed() {
assert_eq!( assert_eq!(

View File

@@ -175,6 +175,7 @@ impl App {
self.remember_project(project_id); self.remember_project(project_id);
self.selected_session = None; self.selected_session = None;
self.conversation.clear(); self.conversation.clear();
self.chat_follow_tail = true;
self.context_notice = None; self.context_notice = None;
self.clear_a2ui(); self.clear_a2ui();
self.composer.clear(); self.composer.clear();
@@ -189,6 +190,7 @@ impl App {
pub(super) fn discard_session(&mut self, project_id: i32) { pub(super) fn discard_session(&mut self, project_id: i32) {
if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) { if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) {
self.conversation.clear(); self.conversation.clear();
self.chat_follow_tail = true;
self.context_notice = None; self.context_notice = None;
self.clear_a2ui(); self.clear_a2ui();
self.composer.clear(); self.composer.clear();

View File

@@ -315,9 +315,14 @@ impl App {
.style(preference_group_style), .style(preference_group_style),
); );
} }
let transcript = scrollable(messages) let transcript = if self.chat_follow_tail {
.id(chat_scroll_id()) scrollable(messages).anchor_bottom()
.height(Length::Fill); } else {
scrollable(messages)
}
.id(chat_scroll_id())
.on_scroll(Message::ChatScrolled)
.height(Length::Fill);
let conversation = column![ let conversation = column![
transcript, transcript,
container(composer_content,) container(composer_content,)