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