From 8f86cd758ad49001a6dd06f834d9fd750eaa32af Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 21 Mar 2025 03:19:41 -0300 Subject: [PATCH] assistant2: Add design refinements (#27160) Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: Antonio Scandurra Co-authored-by: Agus Zubiaga --- Cargo.lock | 1 + assets/icons/arrow_up_right_alt.svg | 3 + assets/icons/brain.svg | 1 + assets/keymaps/default-macos.json | 4 +- crates/assistant2/Cargo.toml | 1 + crates/assistant2/src/active_thread.rs | 383 +++++++++++++++++------- crates/assistant2/src/message_editor.rs | 327 +++++++++++--------- crates/assistant2/src/thread.rs | 27 +- crates/git_ui/src/git_panel.rs | 2 +- crates/ui/src/components/disclosure.rs | 18 +- crates/ui/src/components/icon.rs | 2 + 11 files changed, 521 insertions(+), 248 deletions(-) create mode 100644 assets/icons/arrow_up_right_alt.svg create mode 100644 assets/icons/brain.svg diff --git a/Cargo.lock b/Cargo.lock index 4beafa67037c43624c3d3a0b39ad8a4996d46588..1c24bf93254a520eaf56f9065581d0f514c4e8e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -495,6 +495,7 @@ dependencies = [ "serde", "serde_json", "settings", + "smallvec", "smol", "streaming_diff", "telemetry", diff --git a/assets/icons/arrow_up_right_alt.svg b/assets/icons/arrow_up_right_alt.svg new file mode 100644 index 0000000000000000000000000000000000000000..4e923c6867ac1ac396d2180827af390941c291a4 --- /dev/null +++ b/assets/icons/arrow_up_right_alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/brain.svg b/assets/icons/brain.svg new file mode 100644 index 0000000000000000000000000000000000000000..80c93814f7c483f9e90d20f81e4ce7d32459ab57 --- /dev/null +++ b/assets/icons/brain.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 5d956ab591a7ecef3c59c0aef156cbf266cce2f1..e5e44e6418dd94bafb65c1ec098c59ff50260b0f 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -287,7 +287,9 @@ "context": "MessageEditor > Editor", "use_key_equivalents": true, "bindings": { - "enter": "assistant2::Chat" + "enter": "assistant2::Chat", + "cmd-g d": "git::Diff", + "shift-escape": "git::ExpandCommitEditor" } }, { diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index f14b6c184a8c807eb337ee40720570fd6fa4c9f1..66663c7a2316f44daadba776e5b9733148b77887 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -65,6 +65,7 @@ scripting_tool.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true +smallvec.workspace = true smol.workspace = true streaming_diff.workspace = true telemetry.workspace = true diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs index 4cc89784fa48395a6336413ebd6d7c9cd01433ee..b3363fb73a9765698e13ab009071a8a676f80258 100644 --- a/crates/assistant2/src/active_thread.rs +++ b/crates/assistant2/src/active_thread.rs @@ -1,5 +1,5 @@ use crate::thread::{ - LastRestoreCheckpoint, MessageId, RequestKind, Thread, ThreadError, ThreadEvent, + LastRestoreCheckpoint, MessageId, RequestKind, Thread, ThreadError, ThreadEvent, ThreadFeedback, }; use crate::thread_store::ThreadStore; use crate::tool_use::{ToolUse, ToolUseStatus}; @@ -20,7 +20,7 @@ use settings::Settings as _; use std::sync::Arc; use std::time::Duration; use theme::ThemeSettings; -use ui::{prelude::*, Disclosure, KeyBinding, Tooltip}; +use ui::{prelude::*, Disclosure, IconButton, KeyBinding, Tooltip}; use util::ResultExt as _; use workspace::{OpenOptions, Workspace}; @@ -593,6 +593,24 @@ impl ActiveThread { self.confirm_editing_message(&menu::Confirm, window, cx); } + fn handle_feedback_click( + &mut self, + feedback: ThreadFeedback, + _window: &mut Window, + cx: &mut Context, + ) { + let report = self + .thread + .update(cx, |thread, cx| thread.report_feedback(feedback, cx)); + + let this = cx.entity().downgrade(); + cx.spawn(async move |_, cx| { + report.await?; + this.update(cx, |_this, cx| cx.notify()) + }) + .detach_and_log_err(cx); + } + fn render_message(&self, ix: usize, window: &mut Window, cx: &mut Context) -> AnyElement { let message_id = self.messages[ix]; let Some(message) = self.thread.read(cx).message(message_id) else { @@ -627,25 +645,127 @@ impl ActiveThread { .filter(|(id, _)| *id == message_id) .map(|(_, state)| state.editor.clone()); + let first_message = ix == 0; + let is_last_message = ix == self.messages.len() - 1; + let colors = cx.theme().colors(); + let active_color = colors.element_active; + let editor_bg_color = colors.editor_background; + let bg_user_message_header = editor_bg_color.blend(active_color.opacity(0.25)); + + let feedback_container = h_flex().pb_4().px_4().gap_1().justify_between(); + let feedback_items = match self.thread.read(cx).feedback() { + Some(feedback) => feedback_container + .child( + Label::new(match feedback { + ThreadFeedback::Positive => "Thanks for your feedback!", + ThreadFeedback::Negative => { + "We appreciate your feedback and will use it to improve." + } + }) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + .child( + h_flex() + .gap_1() + .child( + IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) + .icon_size(IconSize::XSmall) + .icon_color(match feedback { + ThreadFeedback::Positive => Color::Accent, + ThreadFeedback::Negative => Color::Ignored, + }) + .shape(ui::IconButtonShape::Square) + .tooltip(Tooltip::text("Helpful Response")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click( + ThreadFeedback::Positive, + window, + cx, + ); + })), + ) + .child( + IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) + .icon_size(IconSize::XSmall) + .icon_color(match feedback { + ThreadFeedback::Positive => Color::Ignored, + ThreadFeedback::Negative => Color::Accent, + }) + .shape(ui::IconButtonShape::Square) + .tooltip(Tooltip::text("Not Helpful")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click( + ThreadFeedback::Negative, + window, + cx, + ); + })), + ), + ) + .into_any_element(), + None => feedback_container + .child( + Label::new( + "Rating the thread sends all of your current conversation to the Zed team.", + ) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + .child( + h_flex() + .gap_1() + .child( + IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) + .icon_size(IconSize::XSmall) + .icon_color(Color::Ignored) + .shape(ui::IconButtonShape::Square) + .tooltip(Tooltip::text("Helpful Response")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click( + ThreadFeedback::Positive, + window, + cx, + ); + })), + ) + .child( + IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) + .icon_size(IconSize::XSmall) + .icon_color(Color::Ignored) + .shape(ui::IconButtonShape::Square) + .tooltip(Tooltip::text("Not Helpful")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click( + ThreadFeedback::Negative, + window, + cx, + ); + })), + ), + ) + .into_any_element(), + }; let message_content = v_flex() + .gap_1p5() .child( if let Some(edit_message_editor) = edit_message_editor.clone() { div() .key_context("EditMessageEditor") .on_action(cx.listener(Self::cancel_editing_message)) .on_action(cx.listener(Self::confirm_editing_message)) - .p_2p5() + .min_h_6() .child(edit_message_editor) } else { - div().text_ui(cx).child(markdown.clone()) + div().min_h_6().text_ui(cx).child(markdown.clone()) }, ) .when_some(context, |parent, context| { if !context.is_empty() { parent.child( - h_flex().flex_wrap().gap_1().px_1p5().pb_1p5().children( + h_flex().flex_wrap().gap_1().children( context .into_iter() .map(|context| ContextPill::added(context, false, false, None)), @@ -659,7 +779,7 @@ impl ActiveThread { let styled_message = match message.role { Role::User => v_flex() .id(("message-container", ix)) - .pt_2() + .py_2() .pl_2() .pr_2p5() .child( @@ -674,11 +794,11 @@ impl ActiveThread { .py_1() .pl_2() .pr_1() - .bg(colors.editor_foreground.opacity(0.05)) + .bg(bg_user_message_header) .border_b_1() .border_color(colors.border) .justify_between() - .rounded_t(px(6.)) + .rounded_t_md() .child( h_flex() .gap_1p5() @@ -693,14 +813,19 @@ impl ActiveThread { .color(Color::Muted), ), ) - .when_some( - edit_message_editor.clone(), - |this, edit_message_editor| { - let focus_handle = edit_message_editor.focus_handle(cx); - this.child( - h_flex() - .gap_1() - .child( + .child( + h_flex() + // DL: To double-check whether we want to fully remove + // the editing feature from meassages. Checkpoint sort of + // solve the same problem. + .invisible() + .gap_1() + .when_some( + edit_message_editor.clone(), + |this, edit_message_editor| { + let focus_handle = + edit_message_editor.focus_handle(cx); + this.child( Button::new("cancel-edit-message", "Cancel") .label_size(LabelSize::Small) .key_binding( @@ -734,36 +859,36 @@ impl ActiveThread { .on_click( cx.listener(Self::handle_regenerate_click), ), - ), - ) - }, - ) - .when( - edit_message_editor.is_none() && allow_editing_message, - |this| { - this.child( - Button::new("edit-message", "Edit") - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let message_text = message.text.clone(); - move |this, _, window, cx| { - this.start_editing_message( - message_id, - message_text.clone(), - window, - cx, - ); - } - })), + ) + }, ) - }, + .when( + edit_message_editor.is_none() && allow_editing_message, + |this| { + this.child( + Button::new("edit-message", "Edit") + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let message_text = message.text.clone(); + move |this, _, window, cx| { + this.start_editing_message( + message_id, + message_text.clone(), + window, + cx, + ); + } + })), + ) + }, + ), ), ) .child(div().p_2().child(message_content)), ), Role::Assistant => v_flex() .id(("message-container", ix)) - .child(div().py_3().px_4().child(message_content)) + .child(v_flex().py_2().px_4().child(message_content)) .when( !tool_uses.is_empty() || !scripting_tool_uses.is_empty(), |parent| { @@ -789,8 +914,12 @@ impl ActiveThread { }; v_flex() - .when(ix == 0, |parent| parent.child(self.render_rules_item(cx))) - .when_some(checkpoint, |parent, checkpoint| { + .w_full() + .when(first_message, |parent| { + parent.child(self.render_rules_item(cx)) + }) + .when(!first_message && checkpoint.is_some(), |parent| { + let checkpoint = checkpoint.clone().unwrap(); let mut is_pending = false; let mut error = None; if let Some(last_restore_checkpoint) = @@ -813,13 +942,15 @@ impl ActiveThread { } else { IconName::Undo }) - .size(ButtonSize::Compact) - .disabled(is_pending) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::Start) .icon_color(if error.is_some() { Some(Color::Error) } else { None }) + .label_size(LabelSize::XSmall) + .disabled(is_pending) .on_click(cx.listener(move |this, _, _window, cx| { this.thread.update(cx, |thread, cx| { thread @@ -846,9 +977,21 @@ impl ActiveThread { restore_checkpoint_button.into_any_element() }; - parent.child(h_flex().pl_2().child(restore_checkpoint_button)) + parent.child( + h_flex() + .px_2p5() + .w_full() + .gap_1() + .child(ui::Divider::horizontal()) + .child(restore_checkpoint_button) + .child(ui::Divider::horizontal()), + ) }) .child(styled_message) + .when( + is_last_message && !self.thread.read(cx).is_generating(), + |parent| parent.child(feedback_items), + ) .into_any() } @@ -861,17 +1004,33 @@ impl ActiveThread { let lighter_border = cx.theme().colors().border.opacity(0.5); + let tool_icon = match tool_use.name.as_ref() { + "bash" => IconName::Terminal, + "delete-path" => IconName::Trash, + "diagnostics" => IconName::Warning, + "edit-files" => IconName::Pencil, + "fetch" => IconName::Globe, + "list-directory" => IconName::Folder, + "now" => IconName::Info, + "path-search" => IconName::SearchCode, + "read-file" => IconName::Eye, + "regex-search" => IconName::Regex, + "thinking" => IconName::Brain, + _ => IconName::Terminal, + }; + div().px_4().child( v_flex() .rounded_lg() .border_1() .border_color(lighter_border) + .overflow_hidden() .child( h_flex() + .group("disclosure-header") .justify_between() .py_1() - .pl_1() - .pr_2() + .px_2() .bg(cx.theme().colors().editor_foreground.opacity(0.025)) .map(|element| { if is_open { @@ -883,54 +1042,79 @@ impl ActiveThread { .border_color(lighter_border) .child( h_flex() - .gap_1() - .child(Disclosure::new("tool-use-disclosure", is_open).on_click( - cx.listener({ - let tool_use_id = tool_use.id.clone(); - move |this, _event, _window, _cx| { - let is_open = this - .expanded_tool_uses - .entry(tool_use_id.clone()) - .or_insert(false); - - *is_open = !*is_open; - } - }), - )) - .child(div().text_ui_sm(cx).children( - self.rendered_tool_use_labels.get(&tool_use.id).cloned(), - )) - .truncate(), + .gap_1p5() + .child( + Icon::new(tool_icon) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + div() + .text_ui_sm(cx) + .children( + self.rendered_tool_use_labels + .get(&tool_use.id) + .cloned(), + ) + .truncate(), + ), ) - .child({ - let (icon_name, color, animated) = match &tool_use.status { - ToolUseStatus::Pending => { - (IconName::Warning, Color::Warning, false) - } - ToolUseStatus::Running => { - (IconName::ArrowCircle, Color::Accent, true) - } - ToolUseStatus::Finished(_) => { - (IconName::Check, Color::Success, false) - } - ToolUseStatus::Error(_) => (IconName::Close, Color::Error, false), - }; - - let icon = Icon::new(icon_name).color(color).size(IconSize::Small); - - if animated { - icon.with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage(delta))) - }, + .child( + h_flex() + .gap_1() + .child( + div().visible_on_hover("disclosure-header").child( + Disclosure::new("tool-use-disclosure", is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .on_click(cx.listener({ + let tool_use_id = tool_use.id.clone(); + move |this, _event, _window, _cx| { + let is_open = this + .expanded_tool_uses + .entry(tool_use_id.clone()) + .or_insert(false); + + *is_open = !*is_open; + } + })), + ), ) - .into_any_element() - } else { - icon.into_any_element() - } - }), + .child({ + let (icon_name, color, animated) = match &tool_use.status { + ToolUseStatus::Pending => { + (IconName::Warning, Color::Warning, false) + } + ToolUseStatus::Running => { + (IconName::ArrowCircle, Color::Accent, true) + } + ToolUseStatus::Finished(_) => { + (IconName::Check, Color::Success, false) + } + ToolUseStatus::Error(_) => { + (IconName::Close, Color::Error, false) + } + }; + + let icon = + Icon::new(icon_name).color(color).size(IconSize::Small); + + if animated { + icon.with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate(percentage( + delta, + ))) + }, + ) + .into_any_element() + } else { + icon.into_any_element() + } + }), + ), ) .map(|parent| { if !is_open { @@ -1171,10 +1355,8 @@ impl ActiveThread { .px_2p5() .child( h_flex() - .group("rules-item") .w_full() - .gap_2() - .justify_between() + .gap_0p5() .child( h_flex() .gap_1p5() @@ -1191,11 +1373,12 @@ impl ActiveThread { ), ) .child( - div().visible_on_hover("rules-item").child( - Button::new("open-rules", "Open Rules") - .label_size(LabelSize::XSmall) - .on_click(cx.listener(Self::handle_open_rules)), - ), + IconButton::new("open-rule", IconName::ArrowUpRightAlt) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Ignored) + .on_click(cx.listener(Self::handle_open_rules)) + .tooltip(Tooltip::text("View Rules")), ), ) .into_any() diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index 8048875495c88a9423862fd86754d764e72fca01..4baa1da3be5018d85468276046d6509903e26ece 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -7,7 +7,7 @@ use fs::Fs; use git::ExpandCommitEditor; use git_ui::git_panel; use gpui::{ - Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle, + point, Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle, WeakEntity, }; use language_model::LanguageModelRegistry; @@ -23,8 +23,7 @@ use ui::{ }; use util::ResultExt; use vim_mode_setting::VimModeSetting; -use workspace::notifications::{NotificationId, NotifyTaskExt}; -use workspace::{Toast, Workspace}; +use workspace::Workspace; use crate::assistant_model_selector::AssistantModelSelector; use crate::context_picker::{ConfirmBehavior, ContextPicker}; @@ -38,6 +37,7 @@ use crate::{Chat, ChatMode, RemoveAllContext, ThreadEvent, ToggleContextPicker}; pub struct MessageEditor { thread: Entity, editor: Entity, + #[allow(dead_code)] workspace: WeakEntity, project: Entity, context_store: Entity, @@ -144,7 +144,6 @@ impl MessageEditor { ) { self.context_picker_menu_handle.toggle(window, cx); } - pub fn remove_all_context( &mut self, _: &RemoveAllContext, @@ -301,34 +300,6 @@ impl MessageEditor { self.context_strip.focus_handle(cx).focus(window); } } - - fn handle_feedback_click( - &mut self, - is_positive: bool, - window: &mut Window, - cx: &mut Context, - ) { - let workspace = self.workspace.clone(); - let report = self - .thread - .update(cx, |thread, cx| thread.report_feedback(is_positive, cx)); - - cx.spawn(async move |_, cx| { - report.await?; - workspace.update(cx, |workspace, cx| { - let message = if is_positive { - "Positive feedback recorded. Thank you!" - } else { - "Negative feedback recorded. Thank you for helping us improve!" - }; - - struct ThreadFeedback; - let id = NotificationId::unique::(); - workspace.show_toast(Toast::new(id, message).autohide(), cx) - }) - }) - .detach_and_notify_err(window, cx); - } } impl Focusable for MessageEditor { @@ -341,9 +312,11 @@ impl Render for MessageEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let font_size = TextSize::Default.rems(cx); let line_height = font_size.to_pixels(window.rem_size()) * 1.5; + let focus_handle = self.editor.focus_handle(cx); let inline_context_picker = self.inline_context_picker.clone(); - let bg_color = cx.theme().colors().editor_background; + + let empty_thread = self.thread.read(cx).is_empty(); let is_generating = self.thread.read(cx).is_generating(); let is_model_selected = self.is_model_selected(cx); let is_editor_empty = self.is_editor_empty(cx); @@ -370,6 +343,24 @@ impl Render for MessageEditor { 0 }; + let border_color = cx.theme().colors().border; + let active_color = cx.theme().colors().element_selected; + let editor_bg_color = cx.theme().colors().editor_background; + let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3)); + + let edit_files_container = || { + h_flex() + .mx_2() + .py_1() + .pl_2p5() + .pr_1() + .bg(bg_edit_files_disclosure) + .border_1() + .border_color(border_color) + .justify_between() + .flex_wrap() + }; + v_flex() .size_full() .when(is_generating, |parent| { @@ -381,7 +372,7 @@ impl Render for MessageEditor { .pl_2() .pr_1() .py_1() - .bg(cx.theme().colors().editor_background) + .bg(editor_bg_color) .border_1() .border_color(cx.theme().colors().border_variant) .rounded_lg() @@ -430,73 +421,163 @@ impl Render for MessageEditor { ), ) }) - .when(changed_files > 0, |parent| { - parent.child( - v_flex() - .mx_2() - .bg(cx.theme().colors().element_background) - .border_1() - .border_b_0() - .border_color(cx.theme().colors().border) - .rounded_t_md() - .child( - h_flex() - .justify_between() - .p_2() - .child( - h_flex() - .gap_2() - .child( - IconButton::new( - "edits-disclosure", - IconName::GitBranchSmall, - ) - .icon_size(IconSize::Small) - .on_click( - |_ev, _window, cx| { - cx.defer(|cx| { - cx.dispatch_action(&git_panel::ToggleFocus) - }); - }, - ), - ) - .child( - Label::new(format!( - "{} {} changed", - changed_files, - if changed_files == 1 { "file" } else { "files" } - )) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("review", "Review") - .label_size(LabelSize::XSmall) - .on_click(|_event, _window, cx| { - cx.defer(|cx| { - cx.dispatch_action( - &git_ui::project_diff::Diff, - ); - }); - }), - ) - .child( - Button::new("commit", "Commit") - .label_size(LabelSize::XSmall) - .on_click(|_event, _window, cx| { - cx.defer(|cx| { - cx.dispatch_action(&ExpandCommitEditor) - }); - }), - ), - ), - ), - ) - }) + .when( + changed_files > 0 && !is_generating && !empty_thread, + |parent| { + parent.child( + edit_files_container() + .border_b_0() + .rounded_t_md() + .shadow(smallvec::smallvec![gpui::BoxShadow { + color: gpui::black().opacity(0.15), + offset: point(px(1.), px(-1.)), + blur_radius: px(3.), + spread_radius: px(0.), + }]) + .child( + h_flex() + .gap_2() + .child(Label::new("Edits").size(LabelSize::XSmall)) + .child(div().size_1().rounded_full().bg(border_color)) + .child( + Label::new(format!( + "{} {}", + changed_files, + if changed_files == 1 { "file" } else { "files" } + )) + .size(LabelSize::XSmall), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("panel", "Open Git Panel") + .label_size(LabelSize::XSmall) + .key_binding({ + let focus_handle = focus_handle.clone(); + KeyBinding::for_action_in( + &git_panel::ToggleFocus, + &focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))) + }) + .on_click(|_ev, _window, cx| { + cx.defer(|cx| { + cx.dispatch_action(&git_panel::ToggleFocus) + }); + }), + ) + .child( + Button::new("review", "Review Diff") + .label_size(LabelSize::XSmall) + .key_binding({ + let focus_handle = focus_handle.clone(); + KeyBinding::for_action_in( + &git_ui::project_diff::Diff, + &focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))) + }) + .on_click(|_event, _window, cx| { + cx.defer(|cx| { + cx.dispatch_action(&git_ui::project_diff::Diff) + }); + }), + ) + .child( + Button::new("commit", "Commit Changes") + .label_size(LabelSize::XSmall) + .key_binding({ + let focus_handle = focus_handle.clone(); + KeyBinding::for_action_in( + &ExpandCommitEditor, + &focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))) + }) + .on_click(|_event, _window, cx| { + cx.defer(|cx| { + cx.dispatch_action(&ExpandCommitEditor) + }); + }), + ), + ), + ) + }, + ) + .when( + changed_files > 0 && !is_generating && empty_thread, + |parent| { + parent.child( + edit_files_container() + .mb_2() + .rounded_md() + .child( + h_flex() + .gap_2() + .child(Label::new("Consider committing your changes before starting a fresh thread").size(LabelSize::XSmall)) + .child(div().size_1().rounded_full().bg(border_color)) + .child( + Label::new(format!( + "{} {}", + changed_files, + if changed_files == 1 { "file" } else { "files" } + )) + .size(LabelSize::XSmall), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("review", "Review Diff") + .label_size(LabelSize::XSmall) + .key_binding({ + let focus_handle = focus_handle.clone(); + KeyBinding::for_action_in( + &git_ui::project_diff::Diff, + &focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))) + }) + .on_click(|_event, _window, cx| { + cx.defer(|cx| { + cx.dispatch_action(&git_ui::project_diff::Diff) + }); + }), + ) + .child( + Button::new("commit", "Commit Changes") + .label_size(LabelSize::XSmall) + .key_binding({ + let focus_handle = focus_handle.clone(); + KeyBinding::for_action_in( + &ExpandCommitEditor, + &focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))) + }) + .on_click(|_event, _window, cx| { + cx.defer(|cx| { + cx.dispatch_action(&ExpandCommitEditor) + }); + }), + ), + ), + ) + }, + ) .child( v_flex() .key_context("MessageEditor") @@ -511,48 +592,10 @@ impl Render for MessageEditor { .on_action(cx.listener(Self::toggle_chat_mode)) .gap_2() .p_2() - .bg(bg_color) + .bg(editor_bg_color) .border_t_1() .border_color(cx.theme().colors().border) - .child( - h_flex() - .justify_between() - .child(self.context_strip.clone()) - .when(!self.thread.read(cx).is_empty(), |this| { - this.child( - h_flex() - .gap_2() - .child( - IconButton::new( - "feedback-thumbs-up", - IconName::ThumbsUp, - ) - .style(ButtonStyle::Subtle) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Helpful")) - .on_click( - cx.listener(|this, _, window, cx| { - this.handle_feedback_click(true, window, cx); - }), - ), - ) - .child( - IconButton::new( - "feedback-thumbs-down", - IconName::ThumbsDown, - ) - .style(ButtonStyle::Subtle) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Not Helpful")) - .on_click( - cx.listener(|this, _, window, cx| { - this.handle_feedback_click(false, window, cx); - }), - ), - ), - ) - }), - ) + .child(h_flex().justify_between().child(self.context_strip.clone())) .child( v_flex() .gap_5() @@ -572,7 +615,7 @@ impl Render for MessageEditor { EditorElement::new( &self.editor, EditorStyle { - background: bg_color, + background: editor_bg_color, local_player: cx.theme().players().local(), text: text_style, ..Default::default() diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index 876662d386884495e9f71bf25ce254826bceeb24..c2cf4fe5509e55137feeb9f8ac4761e28d1a14c9 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -99,6 +99,12 @@ pub struct ThreadCheckpoint { git_checkpoint: GitStoreCheckpoint, } +#[derive(Copy, Clone, Debug)] +pub enum ThreadFeedback { + Positive, + Negative, +} + pub enum LastRestoreCheckpoint { Pending { message_id: MessageId, @@ -142,6 +148,7 @@ pub struct Thread { scripting_tool_use: ToolUseState, initial_project_snapshot: Shared>>>, cumulative_token_usage: TokenUsage, + feedback: Option, } impl Thread { @@ -179,6 +186,7 @@ impl Thread { .shared() }, cumulative_token_usage: TokenUsage::default(), + feedback: None, } } @@ -239,6 +247,7 @@ impl Thread { initial_project_snapshot: Task::ready(serialized.initial_project_snapshot).shared(), // TODO: persist token usage? cumulative_token_usage: TokenUsage::default(), + feedback: None, } } @@ -1187,12 +1196,23 @@ impl Thread { } } + /// Returns the feedback given to the thread, if any. + pub fn feedback(&self) -> Option { + self.feedback + } + /// Reports feedback about the thread and stores it in our telemetry backend. - pub fn report_feedback(&self, is_positive: bool, cx: &mut Context) -> Task> { + pub fn report_feedback( + &mut self, + feedback: ThreadFeedback, + cx: &mut Context, + ) -> Task> { let final_project_snapshot = Self::project_snapshot(self.project.clone(), cx); let serialized_thread = self.serialize(cx); let thread_id = self.id().clone(); let client = self.project.read(cx).client(); + self.feedback = Some(feedback); + cx.notify(); cx.background_spawn(async move { let final_project_snapshot = final_project_snapshot.await; @@ -1200,7 +1220,10 @@ impl Thread { let thread_data = serde_json::to_value(serialized_thread).unwrap_or_else(|_| serde_json::Value::Null); - let rating = if is_positive { "positive" } else { "negative" }; + let rating = match feedback { + ThreadFeedback::Positive => "positive", + ThreadFeedback::Negative => "negative", + }; telemetry::event!( "Assistant Thread Rated", rating, diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index d4cd4da52e5e94e6d6768bd26e670bc955e12c6c..a5804b91a8fd3704021c7be6da7e292a2dd251d4 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2786,7 +2786,7 @@ impl GitPanel { panel_button(change_string) .color(Color::Muted) .tooltip(Tooltip::for_action_title_in( - "Open diff", + "Open Diff", &Diff, &self.focus_handle, )) diff --git a/crates/ui/src/components/disclosure.rs b/crates/ui/src/components/disclosure.rs index c39bcf47d50b977a855d9ed6f6f47963a55ee201..1bf34ad6ed3332819cab90c327eb4445c7e9c636 100644 --- a/crates/ui/src/components/disclosure.rs +++ b/crates/ui/src/components/disclosure.rs @@ -11,6 +11,8 @@ pub struct Disclosure { selected: bool, on_toggle: Option>, cursor_style: CursorStyle, + opened_icon: IconName, + closed_icon: IconName, } impl Disclosure { @@ -21,6 +23,8 @@ impl Disclosure { selected: false, on_toggle: None, cursor_style: CursorStyle::PointingHand, + opened_icon: IconName::ChevronDown, + closed_icon: IconName::ChevronRight, } } @@ -31,6 +35,16 @@ impl Disclosure { self.on_toggle = handler.into(); self } + + pub fn opened_icon(mut self, icon: IconName) -> Self { + self.opened_icon = icon; + self + } + + pub fn closed_icon(mut self, icon: IconName) -> Self { + self.closed_icon = icon; + self + } } impl Toggleable for Disclosure { @@ -57,8 +71,8 @@ impl RenderOnce for Disclosure { IconButton::new( self.id, match self.is_open { - true => IconName::ChevronDown, - false => IconName::ChevronRight, + true => self.opened_icon, + false => self.closed_icon, }, ) .shape(IconButtonShape::Square) diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 19aaaf9c476877858fad6cedd4f39768b70484e3..8dc92b1fc32fe100c4e00eca03ce28fc78bdca2d 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -143,6 +143,7 @@ pub enum IconName { ArrowUp, ArrowUpFromLine, ArrowUpRight, + ArrowUpRightAlt, AtSign, AudioOff, AudioOn, @@ -156,6 +157,7 @@ pub enum IconName { Book, BookCopy, BookPlus, + Brain, CaseSensitive, Check, ChevronDown,