Keep chat scrolling under user control
This commit is contained in:
45
src/app.rs
45
src/app.rs
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -315,8 +315,13 @@ impl App {
|
||||
.style(preference_group_style),
|
||||
);
|
||||
}
|
||||
let transcript = scrollable(messages)
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user