diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 740beabce22ab6eb476b8c60b281c3ebc9d9df12..f5c91cf342c69badf2915e21c17f819963416ec5 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -795,7 +795,7 @@ impl ConversationView { }); let count = thread.read(cx).entries().len(); - let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0)); + let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0)); entry_view_state.update(cx, |view_state, cx| { for ix in 0..count { view_state.sync_entry(ix, &thread, window, cx); @@ -1255,9 +1255,11 @@ impl ConversationView { } AcpThreadEvent::Stopped(stop_reason) => { if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { + active.update(cx, |active, cx| { active.thread_retry_status.take(); active.clear_auto_expand_tracking(); + active.list_state.set_follow_tail(false); + active.sync_generating_indicator(cx); }); } if is_subagent { @@ -1325,8 +1327,10 @@ impl ConversationView { } AcpThreadEvent::Error => { if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { + active.update(cx, |active, cx| { active.thread_retry_status.take(); + active.list_state.set_follow_tail(false); + active.sync_generating_indicator(cx); }); } if !is_subagent { diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 1ad07efd52ddcffe29bd3d50e382d85813c3c994..2778a5b4a2583a0b232f86184f33c4446bc18ea5 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -285,6 +285,7 @@ pub struct ThreadView { pub hovered_recent_history_item: Option, pub show_external_source_prompt_warning: bool, pub show_codex_windows_warning: bool, + pub generating_indicator_in_list: bool, pub history: Option>, pub _history_subscription: Option, } @@ -525,19 +526,39 @@ impl ThreadView { history, _history_subscription: history_subscription, show_codex_windows_warning, + generating_indicator_in_list: false, }; + + this.sync_generating_indicator(cx); let list_state_for_scroll = this.list_state.clone(); let thread_view = cx.entity().downgrade(); + this.list_state - .set_scroll_handler(move |_event, _window, cx| { + .set_scroll_handler(move |event, _window, cx| { let list_state = list_state_for_scroll.clone(); let thread_view = thread_view.clone(); + let is_following_tail = event.is_following_tail; // N.B. We must defer because the scroll handler is called while the // ListState's RefCell is mutably borrowed. Reading logical_scroll_top() // directly would panic from a double borrow. cx.defer(move |cx| { let scroll_top = list_state.logical_scroll_top(); let _ = thread_view.update(cx, |this, cx| { + if !is_following_tail { + let is_at_bottom = { + let current_offset = + list_state.scroll_px_offset_for_scrollbar().y.abs(); + let max_offset = list_state.max_offset_for_scrollbar().y; + current_offset >= max_offset - px(1.0) + }; + + let is_generating = + matches!(this.thread.read(cx).status(), ThreadStatus::Generating); + + if is_at_bottom && is_generating { + list_state.set_follow_tail(true); + } + } if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, _cx| { thread.set_ui_scroll_position(Some(scroll_top)); @@ -1043,7 +1064,11 @@ impl ThreadView { this.update_in(cx, |this, _window, cx| { this.set_editor_is_expanded(false, cx); })?; - let _ = this.update(cx, |this, cx| this.scroll_to_bottom(cx)); + + let _ = this.update(cx, |this, cx| { + this.list_state.set_follow_tail(true); + cx.notify(); + }); let _stop_turn = defer({ let this = this.clone(); @@ -1097,6 +1122,12 @@ impl ThreadView { thread.send(contents, cx) })?; + + let _ = this.update(cx, |this, cx| { + this.sync_generating_indicator(cx); + cx.notify(); + }); + let res = send.await; let turn_time_ms = turn_start_time.elapsed().as_millis(); drop(_stop_turn); @@ -1236,13 +1267,13 @@ impl ThreadView { ); } - // generation - pub fn cancel_generation(&mut self, cx: &mut Context) { self.thread_retry_status.take(); self.thread_error.take(); self.user_interrupted_generation = true; self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx))); + self.sync_generating_indicator(cx); + cx.notify(); } pub fn retry_generation(&mut self, cx: &mut Context) { @@ -1254,6 +1285,8 @@ impl ThreadView { } let task = thread.update(cx, |thread, cx| thread.retry(cx)); + self.sync_generating_indicator(cx); + cx.notify(); cx.spawn(async move |this, cx| { let result = task.await; @@ -1582,11 +1615,10 @@ impl ThreadView { } }) }; + self.message_editor.focus_handle(cx).focus(window, cx); cx.notify(); } - // tool permissions - pub fn authorize_tool_call( &mut self, session_id: acp::SessionId, @@ -1640,6 +1672,17 @@ impl ThreadView { Some(()) } + fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool { + if let AgentThreadEntry::ToolCall(tool_call) = entry { + matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { .. } + ) + } else { + false + } + } + fn handle_authorize_tool_call( &mut self, action: &AuthorizeToolCall, @@ -3207,22 +3250,98 @@ impl ThreadView { }) }; - if show_split { - let max_output_tokens = self - .as_native_thread(cx) - .and_then(|thread| thread.read(cx).model()) - .and_then(|model| model.max_output_tokens()) - .unwrap_or(0); + let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); + let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); + let input_tokens_label = + crate::text_thread_editor::humanize_token_count(usage.input_tokens); + let output_tokens_label = + crate::text_thread_editor::humanize_token_count(usage.output_tokens); + + let progress_ratio = if usage.max_tokens > 0 { + usage.used_tokens as f32 / usage.max_tokens as f32 + } else { + 0.0 + }; + let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); + + let tooltip_separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); + + let (user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids) = self + .as_native_thread(cx) + .map(|thread| { + let project_context = thread.read(cx).project_context().read(cx); + let user_rules_count = project_context.user_rules.len(); + let first_user_rules_id = project_context.user_rules.first().map(|r| r.uuid.0); + let project_entry_ids = project_context + .worktrees + .iter() + .filter_map(|wt| wt.rules_file.as_ref()) + .map(|rf| ProjectEntryId::from_usize(rf.project_entry_id)) + .collect::>(); + let project_rules_count = project_entry_ids.len(); + ( + user_rules_count, + first_user_rules_id, + project_rules_count, + project_entry_ids, + ) + }) + .unwrap_or_default(); + + let workspace = self.workspace.clone(); + let max_output_tokens = self + .as_native_thread(cx) + .and_then(|thread| thread.read(cx).model()) + .and_then(|model| model.max_output_tokens()) + .unwrap_or(0); + let input_max_label = crate::text_thread_editor::humanize_token_count( + usage.max_tokens.saturating_sub(max_output_tokens), + ); + let output_max_label = crate::text_thread_editor::humanize_token_count(max_output_tokens); + + let build_tooltip = { + let input_max_label = input_max_label.clone(); + let output_max_label = output_max_label.clone(); + move |_window: &mut Window, cx: &mut App| { + let percentage = percentage.clone(); + let used = used.clone(); + let max = max.clone(); + let input_tokens_label = input_tokens_label.clone(); + let output_tokens_label = output_tokens_label.clone(); + let input_max_label = input_max_label.clone(); + let output_max_label = output_max_label.clone(); + let project_entry_ids = project_entry_ids.clone(); + let workspace = workspace.clone(); + cx.new(move |_cx| TokenUsageTooltip { + percentage, + used, + max, + input_tokens: input_tokens_label, + output_tokens: output_tokens_label, + input_max: input_max_label, + output_max: output_max_label, + show_split, + separator_color: tooltip_separator_color, + user_rules_count, + first_user_rules_id, + project_rules_count, + project_entry_ids, + workspace, + }) + .into() + } + }; + + if show_split { let input = crate::text_thread_editor::humanize_token_count(usage.input_tokens); - let input_max = crate::text_thread_editor::humanize_token_count( - usage.max_tokens.saturating_sub(max_output_tokens), - ); + let input_max = input_max_label; let output = crate::text_thread_editor::humanize_token_count(usage.output_tokens); - let output_max = crate::text_thread_editor::humanize_token_count(max_output_tokens); + let output_max = output_max_label; Some( h_flex() + .id("split_token_usage") .flex_shrink_0() .gap_1() .mr_1p5() @@ -3266,39 +3385,15 @@ impl ThreadView { .color(Color::Muted), ), ) + .hoverable_tooltip(build_tooltip) .into_any_element(), ) } else { - let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); - let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); - let progress_ratio = if usage.max_tokens > 0 { - usage.used_tokens as f32 / usage.max_tokens as f32 - } else { - 0.0 - }; - let progress_color = if progress_ratio >= 0.85 { cx.theme().status().warning } else { cx.theme().colors().text_muted }; - let separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); - - let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); - - let (user_rules_count, project_rules_count) = self - .as_native_thread(cx) - .map(|thread| { - let project_context = thread.read(cx).project_context().read(cx); - let user_rules = project_context.user_rules.len(); - let project_rules = project_context - .worktrees - .iter() - .filter(|wt| wt.rules_file.is_some()) - .count(); - (user_rules, project_rules) - }) - .unwrap_or((0, 0)); Some( h_flex() @@ -3315,53 +3410,7 @@ impl ThreadView { .stroke_width(px(2.)) .progress_color(progress_color), ) - .tooltip(Tooltip::element({ - move |_, cx| { - v_flex() - .min_w_40() - .child( - Label::new("Context") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child( - h_flex() - .gap_0p5() - .child(Label::new(percentage.clone())) - .child(Label::new("•").color(separator_color).mx_1()) - .child(Label::new(used.clone())) - .child(Label::new("/").color(separator_color)) - .child(Label::new(max.clone()).color(Color::Muted)), - ) - .when(user_rules_count > 0 || project_rules_count > 0, |this| { - this.child( - v_flex() - .mt_1p5() - .pt_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new("Rules") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .when(user_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} user rules", - user_rules_count - ))) - }) - .when(project_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} project rules", - project_rules_count - ))) - }), - ) - }) - .into_any_element() - } - })) + .hoverable_tooltip(build_tooltip) .into_any_element(), ) } @@ -3910,16 +3959,184 @@ impl ThreadView { } } +struct TokenUsageTooltip { + percentage: String, + used: String, + max: String, + input_tokens: String, + output_tokens: String, + input_max: String, + output_max: String, + show_split: bool, + separator_color: Color, + user_rules_count: usize, + first_user_rules_id: Option, + project_rules_count: usize, + project_entry_ids: Vec, + workspace: WeakEntity, +} + +impl Render for TokenUsageTooltip { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let separator_color = self.separator_color; + let percentage = self.percentage.clone(); + let used = self.used.clone(); + let max = self.max.clone(); + let input_tokens = self.input_tokens.clone(); + let output_tokens = self.output_tokens.clone(); + let input_max = self.input_max.clone(); + let output_max = self.output_max.clone(); + let show_split = self.show_split; + let user_rules_count = self.user_rules_count; + let first_user_rules_id = self.first_user_rules_id; + let project_rules_count = self.project_rules_count; + let project_entry_ids = self.project_entry_ids.clone(); + let workspace = self.workspace.clone(); + + ui::tooltip_container(cx, move |container, cx| { + container + .min_w_40() + .when(!show_split, |this| { + this.child( + Label::new("Context") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + h_flex() + .gap_0p5() + .child(Label::new(percentage.clone())) + .child(Label::new("\u{2022}").color(separator_color).mx_1()) + .child(Label::new(used.clone())) + .child(Label::new("/").color(separator_color)) + .child(Label::new(max.clone()).color(Color::Muted)), + ) + }) + .when(show_split, |this| { + this.child( + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_0p5() + .child(Label::new("Input:").color(Color::Muted).mr_0p5()) + .child(Label::new(input_tokens)) + .child(Label::new("/").color(separator_color)) + .child(Label::new(input_max).color(Color::Muted)), + ) + .child( + h_flex() + .gap_0p5() + .child(Label::new("Output:").color(Color::Muted).mr_0p5()) + .child(Label::new(output_tokens)) + .child(Label::new("/").color(separator_color)) + .child(Label::new(output_max).color(Color::Muted)), + ), + ) + }) + .when( + user_rules_count > 0 || project_rules_count > 0, + move |this| { + this.child( + v_flex() + .mt_1p5() + .pt_1p5() + .pb_0p5() + .gap_0p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new("Rules") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + v_flex() + .mx_neg_1() + .when(user_rules_count > 0, move |this| { + this.child( + Button::new( + "open-user-rules", + format!("{} user rules", user_rules_count), + ) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(OpenRulesLibrary { + prompt_to_select: first_user_rules_id, + }), + cx, + ); + }), + ) + }) + .when(project_rules_count > 0, move |this| { + let workspace = workspace.clone(); + let project_entry_ids = project_entry_ids.clone(); + this.child( + Button::new( + "open-project-rules", + format!( + "{} project rules", + project_rules_count + ), + ) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .on_click(move |_, window, cx| { + let _ = + workspace.update(cx, |workspace, cx| { + let project = + workspace.project().read(cx); + let paths = project_entry_ids + .iter() + .flat_map(|id| { + project.path_for_entry(*id, cx) + }) + .collect::>(); + for path in paths { + workspace + .open_path( + path, None, true, window, + cx, + ) + .detach_and_log_err(cx); + } + }); + }), + ) + }), + ), + ) + }, + ) + }) + } +} + impl ThreadView { pub(crate) fn render_entries(&mut self, cx: &mut Context) -> List { list( self.list_state.clone(), cx.processor(|this, index: usize, window, cx| { let entries = this.thread.read(cx).entries(); - let Some(entry) = entries.get(index) else { - return Empty.into_any(); - }; - this.render_entry(index, entries.len(), entry, window, cx) + if let Some(entry) = entries.get(index) { + this.render_entry(index, entries.len(), entry, window, cx) + } else if this.generating_indicator_in_list { + let confirmation = entries + .last() + .is_some_and(|entry| Self::is_waiting_for_confirmation(entry)); + this.render_generating(confirmation, cx).into_any_element() + } else { + Empty.into_any() + } }), ) .with_sizing_behavior(gpui::ListSizingBehavior::Auto) @@ -3959,12 +4176,6 @@ impl ThreadView { let editor_focus = editor.focus_handle(cx).is_focused(window); let focus_border = cx.theme().colors().border_focused; - let rules_item = if entry_ix == 0 { - self.render_rules_item(cx) - } else { - None - }; - let has_checkpoint_button = message .checkpoint .as_ref() @@ -3983,10 +4194,6 @@ impl ThreadView { .map(|this| { if is_first_indented { this.pt_0p5() - } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { - this.pt(rems_from_px(18.)) - } else if rules_item.is_some() { - this.pt_3() } else { this.pt_2() } @@ -3995,7 +4202,6 @@ impl ThreadView { .px_2() .gap_1p5() .w_full() - .children(rules_item) .when(is_editable && has_checkpoint_button, |this| { this.children(message.id.clone().map(|message_id| { h_flex() @@ -4250,6 +4456,8 @@ impl ThreadView { primary }; + let thread = self.thread.clone(); + let primary = if is_indented { let line_top = if is_first_indented { rems_from_px(-12.0) @@ -4277,28 +4485,16 @@ impl ThreadView { primary }; - let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry { - matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } - ) - } else { - false - }; + let needs_confirmation = Self::is_waiting_for_confirmation(entry); - let thread = self.thread.clone(); let comments_editor = self.thread_feedback.comments_editor.clone(); let primary = if entry_ix + 1 == total_entries { v_flex() .w_full() .child(primary) - .map(|this| { - if needs_confirmation { - this.child(self.render_generating(true, cx)) - } else { - this.child(self.render_thread_controls(&thread, cx)) - } + .when(!needs_confirmation, |this| { + this.child(self.render_thread_controls(&thread, cx)) }) .when_some(comments_editor, |this, editor| { this.child(Self::render_feedback_feedback_editor(editor, cx)) @@ -4382,7 +4578,7 @@ impl ThreadView { ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); if is_generating { - return self.render_generating(false, cx).into_any_element(); + return Empty.into_any_element(); } let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) @@ -4582,13 +4778,12 @@ impl ThreadView { }); cx.notify(); } else { - self.scroll_to_bottom(cx); + self.scroll_to_end(cx); } } - pub fn scroll_to_bottom(&mut self, cx: &mut Context) { - let entry_count = self.thread.read(cx).entries().len(); - self.list_state.reset(entry_count); + pub fn scroll_to_end(&mut self, cx: &mut Context) { + self.list_state.scroll_to_end(); cx.notify(); } @@ -4669,6 +4864,21 @@ impl ThreadView { }) } + /// Ensures the list item count includes (or excludes) an extra item for the generating indicator + pub(crate) fn sync_generating_indicator(&mut self, cx: &App) { + let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating); + + if is_generating && !self.generating_indicator_in_list { + let entries_count = self.thread.read(cx).entries().len(); + self.list_state.splice(entries_count..entries_count, 1); + self.generating_indicator_in_list = true; + } else if !is_generating && self.generating_indicator_in_list { + let entries_count = self.thread.read(cx).entries().len(); + self.list_state.splice(entries_count..entries_count + 1, 0); + self.generating_indicator_in_list = false; + } + } + fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement { let show_stats = AgentSettings::get_global(cx).show_turn_stats; let elapsed_label = show_stats @@ -4952,7 +5162,7 @@ impl ThreadView { let entity = entity.clone(); move |_, cx| { entity.update(cx, |this, cx| { - this.scroll_to_bottom(cx); + this.scroll_to_end(cx); }); } }) @@ -7423,113 +7633,6 @@ impl ThreadView { } } - fn render_rules_item(&self, cx: &Context) -> Option { - let project_context = self - .as_native_thread(cx)? - .read(cx) - .project_context() - .read(cx); - - let user_rules_text = if project_context.user_rules.is_empty() { - None - } else if project_context.user_rules.len() == 1 { - let user_rules = &project_context.user_rules[0]; - - match user_rules.title.as_ref() { - Some(title) => Some(format!("Using \"{title}\" user rule")), - None => Some("Using user rule".into()), - } - } else { - Some(format!( - "Using {} user rules", - project_context.user_rules.len() - )) - }; - - let first_user_rules_id = project_context - .user_rules - .first() - .map(|user_rules| user_rules.uuid.0); - - let rules_files = project_context - .worktrees - .iter() - .filter_map(|worktree| worktree.rules_file.as_ref()) - .collect::>(); - - let rules_file_text = match rules_files.as_slice() { - &[] => None, - &[rules_file] => Some(format!( - "Using project {:?} file", - rules_file.path_in_worktree - )), - rules_files => Some(format!("Using {} project rules files", rules_files.len())), - }; - - if user_rules_text.is_none() && rules_file_text.is_none() { - return None; - } - - let has_both = user_rules_text.is_some() && rules_file_text.is_some(); - - Some( - h_flex() - .px_2p5() - .child( - Icon::new(IconName::Attach) - .size(IconSize::XSmall) - .color(Color::Disabled), - ) - .when_some(user_rules_text, |parent, user_rules_text| { - parent.child( - h_flex() - .id("user-rules") - .ml_1() - .mr_1p5() - .child( - Label::new(user_rules_text) - .size(LabelSize::XSmall) - .color(Color::Muted) - .truncate(), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View User Rules")) - .on_click(move |_event, window, cx| { - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: first_user_rules_id, - }), - cx, - ) - }), - ) - }) - .when(has_both, |this| { - this.child( - Label::new("•") - .size(LabelSize::XSmall) - .color(Color::Disabled), - ) - }) - .when_some(rules_file_text, |parent, rules_file_text| { - parent.child( - h_flex() - .id("project-rules") - .ml_1p5() - .child( - Label::new(rules_file_text) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View Project Rules")) - .on_click(cx.listener(Self::handle_open_rules)), - ) - }) - .into_any(), - ) - } - fn tool_card_header_bg(&self, cx: &Context) -> Hsla { cx.theme() .colors() diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 578900085334baf27ab90ae77748fb7fd362e8ad..ed441e3b40534690d02b31109e719c60dd5802e0 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -72,6 +72,7 @@ struct StateInner { scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, pending_scroll: Option, + follow_tail: bool, } /// Keeps track of a fractional scroll position within an item for restoration @@ -102,6 +103,9 @@ pub struct ListScrollEvent { /// Whether the list has been scrolled. pub is_scrolled: bool, + + /// Whether the list is currently in follow-tail mode (auto-scrolling to end). + pub is_following_tail: bool, } /// The sizing behavior to apply during layout. @@ -236,6 +240,7 @@ impl ListState { scrollbar_drag_start_height: None, measuring_behavior: ListMeasuringBehavior::default(), pending_scroll: None, + follow_tail: false, }))); this.splice(0..0, item_count); this @@ -394,6 +399,34 @@ impl ListState { }); } + /// Scroll the list to the very end (past the last item). + /// + /// Unlike [`scroll_to_reveal_item`], this uses the total item count as the + /// anchor, so the list's layout pass will walk backwards from the end and + /// always show the bottom of the last item — even when that item is still + /// growing (e.g. during streaming). + pub fn scroll_to_end(&self) { + let state = &mut *self.0.borrow_mut(); + let item_count = state.items.summary().count; + state.logical_scroll_top = Some(ListOffset { + item_ix: item_count, + offset_in_item: px(0.), + }); + } + + /// Set whether the list should automatically follow the tail (auto-scroll to the end). + pub fn set_follow_tail(&self, follow: bool) { + self.0.borrow_mut().follow_tail = follow; + if follow { + self.scroll_to_end(); + } + } + + /// Returns whether the list is currently in follow-tail mode (auto-scrolling to the end). + pub fn is_following_tail(&self) -> bool { + self.0.borrow().follow_tail + } + /// Scroll the list to the given offset pub fn scroll_to(&self, mut scroll_top: ListOffset) { let state = &mut *self.0.borrow_mut(); @@ -559,7 +592,6 @@ impl StateInner { if self.reset { return; } - let padding = self.last_padding.unwrap_or_default(); let scroll_max = (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.)); @@ -581,6 +613,10 @@ impl StateInner { }); } + if self.follow_tail && delta.y > px(0.) { + self.follow_tail = false; + } + if let Some(handler) = self.scroll_handler.as_mut() { let visible_range = Self::visible_range(&self.items, height, scroll_top); handler( @@ -588,6 +624,7 @@ impl StateInner { visible_range, count: self.items.summary().count, is_scrolled: self.logical_scroll_top.is_some(), + is_following_tail: self.follow_tail, }, window, cx, @@ -677,6 +714,15 @@ impl StateInner { let mut rendered_height = padding.top; let mut max_item_width = px(0.); let mut scroll_top = self.logical_scroll_top(); + + if self.follow_tail { + scroll_top = ListOffset { + item_ix: self.items.summary().count, + offset_in_item: px(0.), + }; + self.logical_scroll_top = Some(scroll_top); + } + let mut rendered_focused_item = false; let available_item_space = size( @@ -958,6 +1004,8 @@ impl StateInner { content_height - self.scrollbar_drag_start_height.unwrap_or(content_height); let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max); + self.follow_tail = false; + if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { self.logical_scroll_top = None; } else { @@ -1457,6 +1505,217 @@ mod test { assert_eq!(offset.offset_in_item, px(20.)); } + #[gpui::test] + fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items, each 50px tall → 500px total content, 200px viewport. + // With follow-tail on, the list should always show the bottom. + let item_height = Rc::new(Cell::new(50usize)); + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |_, _, _| { + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.set_follow_tail(true); + + // First paint — items are 50px, total 500px, viewport 200px. + // Follow-tail should anchor to the end. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + // The scroll should be at the bottom: the last visible items fill the + // 200px viewport from the end of 500px of content (offset 300px). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + + // Simulate items growing (e.g. streaming content makes each item taller). + // 10 items × 80px = 800px total. + item_height.set(80); + state.remeasure(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After growth, follow-tail should have re-anchored to the new end. + // 800px total − 200px viewport = 600px offset → item 7 at offset 40px, + // but follow-tail anchors to item_count (10), and layout walks back to + // fill 200px, landing at item 7 (7 × 80 = 560, 800 − 560 = 240 > 200, + // so item 8: 8 × 80 = 640, 800 − 640 = 160 < 200 → keeps walking → + // item 7: offset = 800 − 200 = 600, item_ix = 600/80 = 7, remainder 40). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 7); + assert_eq!(offset.offset_in_item, px(40.)); + assert!(state.is_following_tail()); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + state.set_follow_tail(true); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the user scrolling up. + // This should disengage follow-tail. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(100.))), + ..Default::default() + }); + + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the user scrolls toward the start" + ); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_tail(true); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the scrollbar moving the viewport to the middle. + // `set_offset_from_scrollbar` accepts a positive distance from the start. + state.set_offset_from_scrollbar(point(px(0.), px(150.))); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the scrollbar manually repositions the list" + ); + + // A subsequent draw should preserve the user's manual position instead + // of snapping back to the end. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + } + + #[gpui::test] + fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + // Scroll to the middle of the list (item 3). + state.scroll_to(gpui::ListOffset { + item_ix: 3, + offset_in_item: px(0.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(!state.is_following_tail()); + + // Enable follow-tail — this should immediately snap the scroll anchor + // to the end, like the user just sent a prompt. + state.set_follow_tail(true); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After paint, scroll should be at the bottom. + // 500px total − 200px viewport = 300px offset → item 6, offset 0. + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + } + #[gpui::test] fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) { let cx = cx.add_empty_window();