message_editor.rs
1use std::sync::Arc;
2
3use crate::assistant_model_selector::ModelType;
4use collections::HashSet;
5use editor::actions::MoveUp;
6use editor::{ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorStyle};
7use file_icons::FileIcons;
8use fs::Fs;
9use gpui::{
10 Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle,
11 WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
12};
13use language::Buffer;
14use language_model::{ConfiguredModel, LanguageModelRegistry};
15use language_model_selector::ToggleModelSelector;
16use multi_buffer;
17use project::Project;
18use settings::Settings;
19use std::time::Duration;
20use theme::ThemeSettings;
21use ui::{Disclosure, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
22use util::ResultExt as _;
23use workspace::Workspace;
24
25use crate::assistant_model_selector::AssistantModelSelector;
26use crate::context_picker::{ConfirmBehavior, ContextPicker, ContextPickerCompletionProvider};
27use crate::context_store::{ContextStore, refresh_context_store_text};
28use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
29use crate::profile_selector::ProfileSelector;
30use crate::thread::{RequestKind, Thread, TokenUsageRatio};
31use crate::thread_store::ThreadStore;
32use crate::{
33 AgentDiff, Chat, ChatMode, NewThread, OpenAgentDiff, RemoveAllContext, ThreadEvent,
34 ToggleContextPicker, ToggleProfileSelector,
35};
36
37pub struct MessageEditor {
38 thread: Entity<Thread>,
39 editor: Entity<Editor>,
40 #[allow(dead_code)]
41 workspace: WeakEntity<Workspace>,
42 project: Entity<Project>,
43 context_store: Entity<ContextStore>,
44 context_strip: Entity<ContextStrip>,
45 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
46 inline_context_picker: Entity<ContextPicker>,
47 inline_context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
48 model_selector: Entity<AssistantModelSelector>,
49 profile_selector: Entity<ProfileSelector>,
50 edits_expanded: bool,
51 waiting_for_summaries_to_send: bool,
52 _subscriptions: Vec<Subscription>,
53}
54
55impl MessageEditor {
56 pub fn new(
57 fs: Arc<dyn Fs>,
58 workspace: WeakEntity<Workspace>,
59 context_store: Entity<ContextStore>,
60 thread_store: WeakEntity<ThreadStore>,
61 thread: Entity<Thread>,
62 window: &mut Window,
63 cx: &mut Context<Self>,
64 ) -> Self {
65 let context_picker_menu_handle = PopoverMenuHandle::default();
66 let inline_context_picker_menu_handle = PopoverMenuHandle::default();
67 let model_selector_menu_handle = PopoverMenuHandle::default();
68
69 let editor = cx.new(|cx| {
70 let mut editor = Editor::auto_height(10, window, cx);
71 editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx);
72 editor.set_show_indent_guides(false, cx);
73 editor.set_context_menu_options(ContextMenuOptions {
74 min_entries_visible: 12,
75 max_entries_visible: 12,
76 placement: Some(ContextMenuPlacement::Above),
77 });
78
79 editor
80 });
81
82 let editor_entity = editor.downgrade();
83 editor.update(cx, |editor, _| {
84 editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
85 workspace.clone(),
86 context_store.downgrade(),
87 Some(thread_store.clone()),
88 editor_entity,
89 ))));
90 });
91
92 let inline_context_picker = cx.new(|cx| {
93 ContextPicker::new(
94 workspace.clone(),
95 Some(thread_store.clone()),
96 context_store.downgrade(),
97 ConfirmBehavior::Close,
98 window,
99 cx,
100 )
101 });
102
103 let context_strip = cx.new(|cx| {
104 ContextStrip::new(
105 context_store.clone(),
106 workspace.clone(),
107 Some(thread_store.clone()),
108 context_picker_menu_handle.clone(),
109 SuggestContextKind::File,
110 window,
111 cx,
112 )
113 });
114
115 let subscriptions = vec![
116 cx.subscribe_in(
117 &inline_context_picker,
118 window,
119 Self::handle_inline_context_picker_event,
120 ),
121 cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event),
122 ];
123
124 Self {
125 editor: editor.clone(),
126 project: thread.read(cx).project().clone(),
127 thread,
128 workspace,
129 context_store,
130 context_strip,
131 context_picker_menu_handle,
132 inline_context_picker,
133 inline_context_picker_menu_handle,
134 model_selector: cx.new(|cx| {
135 AssistantModelSelector::new(
136 fs.clone(),
137 model_selector_menu_handle,
138 editor.focus_handle(cx),
139 ModelType::Default,
140 window,
141 cx,
142 )
143 }),
144 edits_expanded: false,
145 waiting_for_summaries_to_send: false,
146 profile_selector: cx
147 .new(|cx| ProfileSelector::new(fs, thread_store, editor.focus_handle(cx), cx)),
148 _subscriptions: subscriptions,
149 }
150 }
151
152 fn toggle_chat_mode(&mut self, _: &ChatMode, _window: &mut Window, cx: &mut Context<Self>) {
153 cx.notify();
154 }
155
156 fn toggle_context_picker(
157 &mut self,
158 _: &ToggleContextPicker,
159 window: &mut Window,
160 cx: &mut Context<Self>,
161 ) {
162 self.context_picker_menu_handle.toggle(window, cx);
163 }
164 pub fn remove_all_context(
165 &mut self,
166 _: &RemoveAllContext,
167 _window: &mut Window,
168 cx: &mut Context<Self>,
169 ) {
170 self.context_store.update(cx, |store, _cx| store.clear());
171 cx.notify();
172 }
173
174 fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
175 if self.is_editor_empty(cx) {
176 return;
177 }
178
179 if self.thread.read(cx).is_generating() {
180 return;
181 }
182
183 self.send_to_model(RequestKind::Chat, window, cx);
184 }
185
186 fn is_editor_empty(&self, cx: &App) -> bool {
187 self.editor.read(cx).text(cx).is_empty()
188 }
189
190 fn is_model_selected(&self, cx: &App) -> bool {
191 LanguageModelRegistry::read_global(cx)
192 .default_model()
193 .is_some()
194 }
195
196 fn send_to_model(
197 &mut self,
198 request_kind: RequestKind,
199 window: &mut Window,
200 cx: &mut Context<Self>,
201 ) {
202 let model_registry = LanguageModelRegistry::read_global(cx);
203 let Some(ConfiguredModel { model, provider }) = model_registry.default_model() else {
204 return;
205 };
206
207 if provider.must_accept_terms(cx) {
208 cx.notify();
209 return;
210 }
211
212 let user_message = self.editor.update(cx, |editor, cx| {
213 let text = editor.text(cx);
214 editor.clear(window, cx);
215 text
216 });
217
218 let refresh_task =
219 refresh_context_store_text(self.context_store.clone(), &HashSet::default(), cx);
220
221 let system_prompt_context_task = self.thread.read(cx).load_system_prompt_context(cx);
222
223 let thread = self.thread.clone();
224 let context_store = self.context_store.clone();
225 let git_store = self.project.read(cx).git_store().clone();
226 let checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx));
227
228 cx.spawn(async move |this, cx| {
229 let checkpoint = checkpoint.await.ok();
230 refresh_task.await;
231 let (system_prompt_context, load_error) = system_prompt_context_task.await;
232
233 thread
234 .update(cx, |thread, cx| {
235 thread.set_system_prompt_context(system_prompt_context);
236 if let Some(load_error) = load_error {
237 cx.emit(ThreadEvent::ShowError(load_error));
238 }
239 })
240 .log_err();
241
242 thread
243 .update(cx, |thread, cx| {
244 let context = context_store.read(cx).context().clone();
245 thread.insert_user_message(user_message, context, checkpoint, cx);
246 })
247 .log_err();
248
249 if let Some(wait_for_summaries) = context_store
250 .update(cx, |context_store, cx| context_store.wait_for_summaries(cx))
251 .log_err()
252 {
253 this.update(cx, |this, cx| {
254 this.waiting_for_summaries_to_send = true;
255 cx.notify();
256 })
257 .log_err();
258
259 wait_for_summaries.await;
260
261 this.update(cx, |this, cx| {
262 this.waiting_for_summaries_to_send = false;
263 cx.notify();
264 })
265 .log_err();
266 }
267
268 // Send to model after summaries are done
269 thread
270 .update(cx, |thread, cx| {
271 thread.send_to_model(model, request_kind, cx);
272 })
273 .log_err();
274 })
275 .detach();
276 }
277
278 fn handle_inline_context_picker_event(
279 &mut self,
280 _inline_context_picker: &Entity<ContextPicker>,
281 _event: &DismissEvent,
282 window: &mut Window,
283 cx: &mut Context<Self>,
284 ) {
285 let editor_focus_handle = self.editor.focus_handle(cx);
286 window.focus(&editor_focus_handle);
287 }
288
289 fn handle_context_strip_event(
290 &mut self,
291 _context_strip: &Entity<ContextStrip>,
292 event: &ContextStripEvent,
293 window: &mut Window,
294 cx: &mut Context<Self>,
295 ) {
296 match event {
297 ContextStripEvent::PickerDismissed
298 | ContextStripEvent::BlurredEmpty
299 | ContextStripEvent::BlurredDown => {
300 let editor_focus_handle = self.editor.focus_handle(cx);
301 window.focus(&editor_focus_handle);
302 }
303 ContextStripEvent::BlurredUp => {}
304 }
305 }
306
307 fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
308 if self.context_picker_menu_handle.is_deployed()
309 || self.inline_context_picker_menu_handle.is_deployed()
310 {
311 cx.propagate();
312 } else {
313 self.context_strip.focus_handle(cx).focus(window);
314 }
315 }
316
317 fn handle_review_click(&self, window: &mut Window, cx: &mut Context<Self>) {
318 AgentDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
319 }
320
321 fn handle_file_click(
322 &self,
323 buffer: Entity<Buffer>,
324 window: &mut Window,
325 cx: &mut Context<Self>,
326 ) {
327 if let Ok(diff) = AgentDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx)
328 {
329 let path_key = multi_buffer::PathKey::for_buffer(&buffer, cx);
330 diff.update(cx, |diff, cx| diff.move_to_path(path_key, window, cx));
331 }
332 }
333}
334
335impl Focusable for MessageEditor {
336 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
337 self.editor.focus_handle(cx)
338 }
339}
340
341impl Render for MessageEditor {
342 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
343 let font_size = TextSize::Default.rems(cx);
344 let line_height = font_size.to_pixels(window.rem_size()) * 1.5;
345
346 let focus_handle = self.editor.focus_handle(cx);
347 let inline_context_picker = self.inline_context_picker.clone();
348
349 let thread = self.thread.read(cx);
350 let is_generating = thread.is_generating();
351 let total_token_usage = thread.total_token_usage(cx);
352 let is_model_selected = self.is_model_selected(cx);
353 let is_editor_empty = self.is_editor_empty(cx);
354 let is_edit_changes_expanded = self.edits_expanded;
355
356 let action_log = self.thread.read(cx).action_log();
357 let changed_buffers = action_log.read(cx).changed_buffers(cx);
358 let changed_buffers_count = changed_buffers.len();
359
360 let editor_bg_color = cx.theme().colors().editor_background;
361 let border_color = cx.theme().colors().border;
362 let active_color = cx.theme().colors().element_selected;
363 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
364
365 v_flex()
366 .size_full()
367 .when(self.waiting_for_summaries_to_send, |parent| {
368 parent.child(
369 h_flex().py_3().w_full().justify_center().child(
370 h_flex()
371 .flex_none()
372 .px_2()
373 .py_2()
374 .bg(editor_bg_color)
375 .border_1()
376 .border_color(cx.theme().colors().border_variant)
377 .rounded_lg()
378 .shadow_md()
379 .gap_1()
380 .child(
381 Icon::new(IconName::ArrowCircle)
382 .size(IconSize::XSmall)
383 .color(Color::Muted)
384 .with_animation(
385 "arrow-circle",
386 Animation::new(Duration::from_secs(2)).repeat(),
387 |icon, delta| {
388 icon.transform(gpui::Transformation::rotate(
389 gpui::percentage(delta),
390 ))
391 },
392 ),
393 )
394 .child(
395 Label::new("Summarizing context…")
396 .size(LabelSize::XSmall)
397 .color(Color::Muted),
398 ),
399 ),
400 )
401 })
402 .when(changed_buffers_count > 0, |parent| {
403 parent.child(
404 v_flex()
405 .mx_2()
406 .bg(bg_edit_files_disclosure)
407 .border_1()
408 .border_b_0()
409 .border_color(border_color)
410 .rounded_t_md()
411 .shadow(smallvec::smallvec![gpui::BoxShadow {
412 color: gpui::black().opacity(0.15),
413 offset: point(px(1.), px(-1.)),
414 blur_radius: px(3.),
415 spread_radius: px(0.),
416 }])
417 .child(
418 h_flex()
419 .id("edits-container")
420 .cursor_pointer()
421 .p_1p5()
422 .justify_between()
423 .when(is_edit_changes_expanded, |this| {
424 this.border_b_1().border_color(border_color)
425 })
426 .on_click(cx.listener(|this, _, window, cx| {
427 this.handle_review_click(window, cx)
428 }))
429 .child(
430 h_flex()
431 .gap_1()
432 .child(
433 Disclosure::new(
434 "edits-disclosure",
435 is_edit_changes_expanded,
436 )
437 .on_click(
438 cx.listener(|this, _ev, _window, cx| {
439 this.edits_expanded = !this.edits_expanded;
440 cx.notify();
441 }),
442 ),
443 )
444 .child(
445 Label::new("Edits")
446 .size(LabelSize::Small)
447 .color(Color::Muted),
448 )
449 .child(
450 Label::new("•")
451 .size(LabelSize::XSmall)
452 .color(Color::Muted),
453 )
454 .child(
455 Label::new(format!(
456 "{} {}",
457 changed_buffers_count,
458 if changed_buffers_count == 1 {
459 "file"
460 } else {
461 "files"
462 }
463 ))
464 .size(LabelSize::Small)
465 .color(Color::Muted),
466 ),
467 )
468 .child(
469 Button::new("review", "Review Changes")
470 .label_size(LabelSize::Small)
471 .key_binding(
472 KeyBinding::for_action_in(
473 &OpenAgentDiff,
474 &focus_handle,
475 window,
476 cx,
477 )
478 .map(|kb| kb.size(rems_from_px(12.))),
479 )
480 .on_click(cx.listener(|this, _, window, cx| {
481 this.handle_review_click(window, cx)
482 })),
483 ),
484 )
485 .when(is_edit_changes_expanded, |parent| {
486 parent.child(
487 v_flex().children(
488 changed_buffers.into_iter().enumerate().flat_map(
489 |(index, (buffer, _diff))| {
490 let file = buffer.read(cx).file()?;
491 let path = file.path();
492
493 let parent_label = path.parent().and_then(|parent| {
494 let parent_str = parent.to_string_lossy();
495
496 if parent_str.is_empty() {
497 None
498 } else {
499 Some(
500 Label::new(format!(
501 "/{}{}",
502 parent_str,
503 std::path::MAIN_SEPARATOR_STR
504 ))
505 .color(Color::Muted)
506 .size(LabelSize::XSmall)
507 .buffer_font(cx),
508 )
509 }
510 });
511
512 let name_label = path.file_name().map(|name| {
513 Label::new(name.to_string_lossy().to_string())
514 .size(LabelSize::XSmall)
515 .buffer_font(cx)
516 });
517
518 let file_icon = FileIcons::get_icon(&path, cx)
519 .map(Icon::from_path)
520 .map(|icon| {
521 icon.color(Color::Muted).size(IconSize::Small)
522 })
523 .unwrap_or_else(|| {
524 Icon::new(IconName::File)
525 .color(Color::Muted)
526 .size(IconSize::Small)
527 });
528
529 let hover_color = cx.theme()
530 .colors()
531 .element_background
532 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
533
534 let overlay_gradient = linear_gradient(
535 90.,
536 linear_color_stop(
537 editor_bg_color,
538 1.,
539 ),
540 linear_color_stop(
541 editor_bg_color
542 .opacity(0.2),
543 0.,
544 ),
545 );
546
547 let overlay_gradient_hover = linear_gradient(
548 90.,
549 linear_color_stop(
550 hover_color,
551 1.,
552 ),
553 linear_color_stop(
554 hover_color
555 .opacity(0.2),
556 0.,
557 ),
558 );
559
560 let element = h_flex()
561 .group("edited-code")
562 .id(("file-container", index))
563 .cursor_pointer()
564 .relative()
565 .py_1()
566 .pl_2()
567 .pr_1()
568 .gap_2()
569 .justify_between()
570 .bg(cx.theme().colors().editor_background)
571 .hover(|style| style.bg(hover_color))
572 .when(index + 1 < changed_buffers_count, |parent| {
573 parent.border_color(border_color).border_b_1()
574 })
575 .child(
576 h_flex()
577 .id("file-name")
578 .pr_8()
579 .gap_1p5()
580 .max_w_full()
581 .overflow_x_scroll()
582 .child(file_icon)
583 .child(
584 h_flex()
585 .gap_0p5()
586 .children(name_label)
587 .children(parent_label)
588 ) // TODO: show lines changed
589 .child(
590 Label::new("+")
591 .color(Color::Created),
592 )
593 .child(
594 Label::new("-")
595 .color(Color::Deleted),
596 ),
597 )
598 .child(
599 div().visible_on_hover("edited-code").child(
600 Button::new("review", "Review")
601 .label_size(LabelSize::Small)
602 .on_click({
603 let buffer = buffer.clone();
604 cx.listener(move |this, _, window, cx| {
605 this.handle_file_click(buffer.clone(), window, cx);
606 })
607 })
608 )
609 )
610 .child(
611 div()
612 .id("gradient-overlay")
613 .absolute()
614 .h_5_6()
615 .w_12()
616 .bottom_0()
617 .right(px(52.))
618 .bg(overlay_gradient)
619 .group_hover("edited-code", |style| style.bg(overlay_gradient_hover))
620 ,
621 )
622 .on_click({
623 let buffer = buffer.clone();
624 cx.listener(move |this, _, window, cx| {
625 this.handle_file_click(buffer.clone(), window, cx);
626 })
627 });
628
629 Some(element)
630 },
631 ),
632 ),
633 )
634 }),
635 )
636 })
637 .child(
638 v_flex()
639 .key_context("MessageEditor")
640 .on_action(cx.listener(Self::chat))
641 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
642 this.profile_selector
643 .read(cx)
644 .menu_handle()
645 .toggle(window, cx);
646 }))
647 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
648 this.model_selector
649 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
650 }))
651 .on_action(cx.listener(Self::toggle_context_picker))
652 .on_action(cx.listener(Self::remove_all_context))
653 .on_action(cx.listener(Self::move_up))
654 .on_action(cx.listener(Self::toggle_chat_mode))
655 .gap_2()
656 .p_2()
657 .bg(editor_bg_color)
658 .border_t_1()
659 .border_color(cx.theme().colors().border)
660 .child(h_flex().justify_between().child(self.context_strip.clone()))
661 .child(
662 v_flex()
663 .gap_5()
664 .child({
665 let settings = ThemeSettings::get_global(cx);
666 let text_style = TextStyle {
667 color: cx.theme().colors().text,
668 font_family: settings.ui_font.family.clone(),
669 font_fallbacks: settings.ui_font.fallbacks.clone(),
670 font_features: settings.ui_font.features.clone(),
671 font_size: font_size.into(),
672 font_weight: settings.ui_font.weight,
673 line_height: line_height.into(),
674 ..Default::default()
675 };
676
677 EditorElement::new(
678 &self.editor,
679 EditorStyle {
680 background: editor_bg_color,
681 local_player: cx.theme().players().local(),
682 text: text_style,
683 syntax: cx.theme().syntax().clone(),
684 ..Default::default()
685 },
686 ).into_any()
687 })
688 .child(
689 PopoverMenu::new("inline-context-picker")
690 .menu(move |window, cx| {
691 inline_context_picker.update(cx, |this, cx| {
692 this.init(window, cx);
693 });
694 Some(inline_context_picker.clone())
695 })
696 .attach(gpui::Corner::TopLeft)
697 .anchor(gpui::Corner::BottomLeft)
698 .offset(gpui::Point {
699 x: px(0.0),
700 y: (-ThemeSettings::get_global(cx).ui_font_size(cx) * 2)
701 - px(4.0),
702 })
703 .with_handle(self.inline_context_picker_menu_handle.clone()),
704 )
705 .child(
706 h_flex()
707 .justify_between()
708 .child(h_flex().gap_2().child(self.profile_selector.clone()))
709 .child(
710 h_flex().gap_1().child(self.model_selector.clone())
711 .map(|parent| {
712 if is_generating {
713 parent.child(
714 IconButton::new("stop-generation", IconName::StopFilled)
715 .icon_color(Color::Error)
716 .style(ButtonStyle::Tinted(ui::TintColor::Error))
717 .tooltip(move |window, cx| {
718 Tooltip::for_action(
719 "Stop Generation",
720 &editor::actions::Cancel,
721 window,
722 cx,
723 )
724 })
725 .on_click(move |_event, window, cx| {
726 focus_handle.dispatch_action(
727 &editor::actions::Cancel,
728 window,
729 cx,
730 );
731 })
732 .with_animation(
733 "pulsating-label",
734 Animation::new(Duration::from_secs(2))
735 .repeat()
736 .with_easing(pulsating_between(0.4, 1.0)),
737 |icon_button, delta| icon_button.alpha(delta),
738 ),
739 )
740 } else {
741 parent.child(
742 IconButton::new("send-message", IconName::Send)
743 .icon_color(Color::Accent)
744 .style(ButtonStyle::Filled)
745 .disabled(
746 is_editor_empty
747 || !is_model_selected
748 || self.waiting_for_summaries_to_send
749 )
750 .on_click(move |_event, window, cx| {
751 focus_handle.dispatch_action(&Chat, window, cx);
752 })
753 .when(!is_editor_empty && is_model_selected, |button| {
754 button.tooltip(move |window, cx| {
755 Tooltip::for_action(
756 "Send",
757 &Chat,
758 window,
759 cx,
760 )
761 })
762 })
763 .when(is_editor_empty, |button| {
764 button.tooltip(Tooltip::text(
765 "Type a message to submit",
766 ))
767 })
768 .when(!is_model_selected, |button| {
769 button.tooltip(Tooltip::text(
770 "Select a model to continue",
771 ))
772 })
773 )
774 }
775 })
776 ),
777 ),
778 )
779 )
780 .when(total_token_usage.ratio != TokenUsageRatio::Normal, |parent| {
781 parent.child(
782 h_flex()
783 .p_2()
784 .gap_2()
785 .flex_wrap()
786 .justify_between()
787 .bg(cx.theme().status().warning_background.opacity(0.1))
788 .border_t_1()
789 .border_color(cx.theme().colors().border)
790 .child(
791 h_flex()
792 .gap_2()
793 .items_start()
794 .child(
795 h_flex()
796 .h(line_height)
797 .justify_center()
798 .child(
799 Icon::new(IconName::Warning)
800 .color(Color::Warning)
801 .size(IconSize::XSmall),
802 ),
803 )
804 .child(
805 v_flex()
806 .mr_auto()
807 .child(Label::new("Thread reaching the token limit soon").size(LabelSize::Small))
808 .child(
809 Label::new(
810 "Start a new thread from a summary to continue the conversation.",
811 )
812 .size(LabelSize::Small)
813 .color(Color::Muted),
814 ),
815 ),
816 )
817 .child(
818 Button::new("new-thread", "Start New Thread")
819 .on_click(cx.listener(|this, _, window, cx| {
820 let from_thread_id = Some(this.thread.read(cx).id().clone());
821
822 window.dispatch_action(Box::new(NewThread {
823 from_thread_id
824 }), cx);
825 }))
826 .icon(IconName::Plus)
827 .icon_position(IconPosition::Start)
828 .icon_size(IconSize::Small)
829 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
830 .label_size(LabelSize::Small),
831 ),
832 )
833 })
834 }
835}