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) 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!(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -315,8 +315,13 @@ impl App {
|
|||||||
.style(preference_group_style),
|
.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())
|
.id(chat_scroll_id())
|
||||||
|
.on_scroll(Message::ChatScrolled)
|
||||||
.height(Length::Fill);
|
.height(Length::Fill);
|
||||||
let conversation = column![
|
let conversation = column![
|
||||||
transcript,
|
transcript,
|
||||||
|
|||||||
Reference in New Issue
Block a user