From 73fb8277fc699600793e39e8320aa812cce48694 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:46:33 +0200 Subject: [PATCH] assistant: Polish /workflow and steps UI (#15936) Fixes #15923 Release Notes: - Assistant workflow steps can now be applied and reverted directly from within the assistant panel. --------- Co-authored-by: Antonio Scandurra Co-authored-by: Antonio --- assets/icons/undo.svg | 1 + crates/assistant/src/assistant.rs | 2 +- crates/assistant/src/assistant_panel.rs | 1073 +++++++++++++---- crates/assistant/src/context.rs | 378 +++--- crates/assistant/src/inline_assistant.rs | 87 +- crates/diagnostics/src/diagnostics.rs | 3 + crates/editor/src/display_map.rs | 2 + crates/editor/src/display_map/block_map.rs | 39 +- crates/editor/src/editor.rs | 8 +- crates/editor/src/editor_tests.rs | 1 + crates/editor/src/element.rs | 1 + crates/editor/src/hunk_diff.rs | 1 + crates/repl/src/session.rs | 1 + .../ui/src/components/button/button_like.rs | 8 + crates/ui/src/components/icon.rs | 2 + 15 files changed, 1157 insertions(+), 450 deletions(-) create mode 100644 assets/icons/undo.svg diff --git a/assets/icons/undo.svg b/assets/icons/undo.svg new file mode 100644 index 0000000000..907cc77195 --- /dev/null +++ b/assets/icons/undo.svg @@ -0,0 +1 @@ + diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 04e4f69505..07b9e921a6 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -53,7 +53,7 @@ actions!( DeployPromptLibrary, ConfirmCommand, ToggleModelSelector, - DebugEditSteps + DebugWorkflowSteps ] ); diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 6d1c1ed261..51c1e8c747 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -9,23 +9,22 @@ use crate::{ SlashCommandCompletionProvider, SlashCommandRegistry, }, terminal_inline_assistant::TerminalInlineAssistant, - Assist, CodegenStatus, ConfirmCommand, Context, ContextEvent, ContextId, ContextStore, - CycleMessageRole, DebugEditSteps, DeployHistory, DeployPromptLibrary, InlineAssist, - InlineAssistId, InlineAssistant, InsertIntoEditor, MessageStatus, ModelSelector, - PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata, - ResolvedWorkflowStep, SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector, - WorkflowStepStatus, + Assist, ConfirmCommand, Context, ContextEvent, ContextId, ContextStore, CycleMessageRole, + DebugWorkflowSteps, DeployHistory, DeployPromptLibrary, InlineAssist, InlineAssistId, + InlineAssistant, InsertIntoEditor, MessageStatus, ModelSelector, PendingSlashCommand, + PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata, ResolvedWorkflowStep, + SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector, }; use crate::{ContextStoreEvent, ShowConfiguration}; -use anyhow::{anyhow, Context as _, Result}; +use anyhow::{anyhow, Result}; use assistant_slash_command::{SlashCommand, SlashCommandOutputSection}; use client::{proto, Client, Status}; use collections::{BTreeSet, HashMap, HashSet}; use editor::{ actions::{FoldAt, MoveToEndOfLine, Newline, ShowCompletions, UnfoldAt}, display_map::{ - BlockDisposition, BlockProperties, BlockStyle, Crease, CustomBlockId, RenderBlock, - ToDisplayPoint, + BlockContext, BlockDisposition, BlockProperties, BlockStyle, Crease, CustomBlockId, + RenderBlock, ToDisplayPoint, }, scroll::{Autoscroll, AutoscrollStrategy, ScrollAnchor}, Anchor, Editor, EditorEvent, ExcerptRange, MultiBuffer, RowExt, ToOffset as _, ToPoint, @@ -34,10 +33,11 @@ use editor::{display_map::CreaseId, FoldPlaceholder}; use fs::Fs; use gpui::{ div, percentage, point, Action, Animation, AnimationExt, AnyElement, AnyView, AppContext, - AsyncWindowContext, ClipboardItem, Context as _, DismissEvent, Empty, Entity, EventEmitter, - FocusHandle, FocusableView, FontWeight, InteractiveElement, IntoElement, Model, ParentElement, - Pixels, ReadGlobal, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, - Task, Transformation, UpdateGlobal, View, ViewContext, VisualContext, WeakView, WindowContext, + AsyncWindowContext, ClipboardItem, Context as _, DismissEvent, Empty, Entity, EntityId, + EventEmitter, FocusHandle, FocusableView, FontWeight, InteractiveElement, IntoElement, Model, + ParentElement, Pixels, ReadGlobal, Render, SharedString, StatefulInteractiveElement, Styled, + Subscription, Task, Transformation, UpdateGlobal, View, ViewContext, VisualContext, WeakView, + WindowContext, }; use indexed_docs::IndexedDocsStore; use language::{ @@ -63,6 +63,7 @@ use std::{ time::Duration, }; use terminal_view::{terminal_panel::TerminalPanel, TerminalView}; +use text::OffsetRangeExt; use ui::TintColor; use ui::{ prelude::*, @@ -1328,10 +1329,275 @@ struct ScrollPosition { cursor: Anchor, } +struct WorkflowStep { + range: Range, + header_block_id: CustomBlockId, + footer_block_id: CustomBlockId, + resolved_step: Option>>, + assist: Option, +} + +impl WorkflowStep { + fn status(&self, cx: &AppContext) -> WorkflowStepStatus { + match self.resolved_step.as_ref() { + Some(Ok(_)) => { + if let Some(assist) = self.assist.as_ref() { + let assistant = InlineAssistant::global(cx); + if assist + .assist_ids + .iter() + .any(|assist_id| assistant.assist_status(*assist_id, cx).is_pending()) + { + WorkflowStepStatus::Pending + } else if assist + .assist_ids + .iter() + .all(|assist_id| assistant.assist_status(*assist_id, cx).is_confirmed()) + { + WorkflowStepStatus::Confirmed + } else if assist + .assist_ids + .iter() + .all(|assist_id| assistant.assist_status(*assist_id, cx).is_done()) + { + WorkflowStepStatus::Done + } else { + WorkflowStepStatus::Idle + } + } else { + WorkflowStepStatus::Idle + } + } + Some(Err(error)) => WorkflowStepStatus::Error(error.clone()), + None => WorkflowStepStatus::Resolving, + } + } +} + +enum WorkflowStepStatus { + Resolving, + Error(Arc), + Idle, + Pending, + Done, + Confirmed, +} + +impl WorkflowStepStatus { + pub(crate) fn is_confirmed(&self) -> bool { + matches!(self, Self::Confirmed) + } + + pub(crate) fn into_element( + &self, + step_range: Range, + focus_handle: FocusHandle, + editor: WeakView, + cx: &mut BlockContext<'_, '_>, + ) -> AnyElement { + let id = EntityId::from(cx.block_id); + match self { + WorkflowStepStatus::Resolving => Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .with_animation( + ("resolving-suggestion-label", id), + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + ) + .into_any_element(), + + WorkflowStepStatus::Error(error) => { + let error = error.clone(); + h_flex() + .gap_2() + .child( + div() + .id("step-resolution-failure") + .child( + Label::new("Step Resolution Failed") + .size(LabelSize::Small) + .color(Color::Error), + ) + .tooltip(move |cx| Tooltip::text(error.to_string(), cx)), + ) + .child( + Button::new(("transform", id), "Retry") + .icon(IconName::Update) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .on_click({ + let editor = editor.clone(); + let step_range = step_range.clone(); + move |_, cx| { + editor + .update(cx, |this, cx| { + this.resolve_workflow_step(step_range.clone(), cx) + }) + .ok(); + } + }), + ) + .into_any() + } + + WorkflowStepStatus::Idle => Button::new(("transform", id), "Transform") + .icon(IconName::Sparkle) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .tooltip(move |cx| { + cx.new_view(|cx| { + Tooltip::new("Transform").key_binding(KeyBinding::for_action_in( + &Assist, + &focus_handle, + cx, + )) + }) + .into() + }) + .on_click({ + let editor = editor.clone(); + let step_range = step_range.clone(); + move |_, cx| { + editor + .update(cx, |this, cx| { + this.apply_workflow_step(step_range.clone(), cx) + }) + .ok(); + } + }) + .into_any_element(), + WorkflowStepStatus::Pending => Button::new(("stop-transformation", id), "Stop") + .icon(IconName::Stop) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .style(ButtonStyle::Tinted(TintColor::Negative)) + .tooltip(move |cx| { + cx.new_view(|cx| { + Tooltip::new("Stop Transformation").key_binding(KeyBinding::for_action_in( + &editor::actions::Cancel, + &focus_handle, + cx, + )) + }) + .into() + }) + .on_click({ + let editor = editor.clone(); + let step_range = step_range.clone(); + move |_, cx| { + editor + .update(cx, |this, cx| { + this.stop_workflow_step(step_range.clone(), cx) + }) + .ok(); + } + }) + .into_any_element(), + WorkflowStepStatus::Done => h_flex() + .gap_1() + .child( + Button::new(("stop-transformation", id), "Reject") + .icon(IconName::Close) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .style(ButtonStyle::Tinted(TintColor::Negative)) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |cx| { + cx.new_view(|cx| { + Tooltip::new("Reject Transformation").key_binding( + KeyBinding::for_action_in( + &editor::actions::Cancel, + &focus_handle, + cx, + ), + ) + }) + .into() + } + }) + .on_click({ + let editor = editor.clone(); + let step_range = step_range.clone(); + move |_, cx| { + editor + .update(cx, |this, cx| { + this.reject_workflow_step(step_range.clone(), cx); + }) + .ok(); + } + }), + ) + .child( + Button::new(("confirm-workflow-step", id), "Accept") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .style(ButtonStyle::Tinted(TintColor::Positive)) + .tooltip(move |cx| { + cx.new_view(|cx| { + Tooltip::new("Accept Transformation").key_binding( + KeyBinding::for_action_in(&Assist, &focus_handle, cx), + ) + }) + .into() + }) + .on_click({ + let editor = editor.clone(); + let step_range = step_range.clone(); + move |_, cx| { + editor + .update(cx, |this, cx| { + this.confirm_workflow_step(step_range.clone(), cx); + }) + .ok(); + } + }), + ) + .into_any_element(), + WorkflowStepStatus::Confirmed => h_flex() + .child( + Button::new(("revert-workflow-step", id), "Undo") + .style(ButtonStyle::Filled) + .icon(Some(IconName::Undo)) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .tooltip(|cx| Tooltip::text("Undo Transformation", cx)) + .on_click({ + let editor = editor.clone(); + let step_range = step_range.clone(); + move |_, cx| { + editor + .update(cx, |this, cx| { + this.undo_workflow_step(step_range.clone(), cx); + }) + .ok(); + } + }), + ) + .into_any_element(), + } + } +} + +#[derive(Debug, Eq, PartialEq)] +struct ActiveWorkflowStep { + range: Range, + resolved: bool, +} + struct WorkflowAssist { editor: WeakView, editor_was_open: bool, assist_ids: Vec, + _observe_assist_status: Task<()>, } pub struct ContextEditor { @@ -1346,9 +1612,9 @@ pub struct ContextEditor { remote_id: Option, pending_slash_command_creases: HashMap, CreaseId>, pending_slash_command_blocks: HashMap, CustomBlockId>, - workflow_assists: HashMap, WorkflowAssist>, - active_workflow_step_range: Option>, _subscriptions: Vec, + workflow_steps: HashMap, WorkflowStep>, + active_workflow_step: Option, assistant_panel: WeakView, error_message: Option, } @@ -1406,8 +1672,8 @@ impl ContextEditor { pending_slash_command_creases: HashMap::default(), pending_slash_command_blocks: HashMap::default(), _subscriptions, - workflow_assists: HashMap::default(), - active_workflow_step_range: None, + workflow_steps: HashMap::default(), + active_workflow_step: None, assistant_panel, error_message: None, }; @@ -1442,17 +1708,19 @@ impl ContextEditor { } fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { - if !self.apply_workflow_step(cx) { + if !self.apply_active_workflow_step(cx) { self.error_message = None; self.send_to_model(cx); cx.notify(); } } - fn apply_workflow_step(&mut self, cx: &mut ViewContext) -> bool { - if let Some(step_range) = self.active_workflow_step_range.as_ref() { - if let Some(assists) = self.workflow_assists.get(&step_range) { - let assist_ids = assists.assist_ids.clone(); + fn apply_workflow_step(&mut self, range: Range, cx: &mut ViewContext) { + self.show_workflow_step(range.clone(), cx); + + if let Some(workflow_step) = self.workflow_steps.get(&range) { + if let Some(assist) = workflow_step.assist.as_ref() { + let assist_ids = assist.assist_ids.clone(); cx.window_context().defer(|cx| { InlineAssistant::update_global(cx, |assistant, cx| { for assist_id in assist_ids { @@ -1460,13 +1728,103 @@ impl ContextEditor { } }) }); - - !assists.assist_ids.is_empty() - } else { - false } - } else { - false + } + } + + fn apply_active_workflow_step(&mut self, cx: &mut ViewContext) -> bool { + let Some(step) = self.active_workflow_step() else { + return false; + }; + + let range = step.range.clone(); + match step.status(cx) { + WorkflowStepStatus::Resolving | WorkflowStepStatus::Pending => true, + WorkflowStepStatus::Idle => { + self.apply_workflow_step(range, cx); + true + } + WorkflowStepStatus::Done => { + self.confirm_workflow_step(range, cx); + true + } + WorkflowStepStatus::Error(_) => { + self.resolve_workflow_step(range, cx); + true + } + WorkflowStepStatus::Confirmed => false, + } + } + + fn resolve_workflow_step( + &mut self, + range: Range, + cx: &mut ViewContext, + ) { + self.context.update(cx, |context, cx| { + context.resolve_workflow_step(range, self.project.clone(), cx) + }); + } + + fn stop_workflow_step(&mut self, range: Range, cx: &mut ViewContext) { + if let Some(workflow_step) = self.workflow_steps.get(&range) { + if let Some(assist) = workflow_step.assist.as_ref() { + let assist_ids = assist.assist_ids.clone(); + cx.window_context().defer(|cx| { + InlineAssistant::update_global(cx, |assistant, cx| { + for assist_id in assist_ids { + assistant.stop_assist(assist_id, cx); + } + }) + }); + } + } + } + + fn undo_workflow_step(&mut self, range: Range, cx: &mut ViewContext) { + if let Some(workflow_step) = self.workflow_steps.get_mut(&range) { + if let Some(assist) = workflow_step.assist.take() { + cx.window_context().defer(|cx| { + InlineAssistant::update_global(cx, |assistant, cx| { + for assist_id in assist.assist_ids { + assistant.undo_assist(assist_id, cx); + } + }) + }); + } + } + } + + fn confirm_workflow_step( + &mut self, + range: Range, + cx: &mut ViewContext, + ) { + if let Some(workflow_step) = self.workflow_steps.get(&range) { + if let Some(assist) = workflow_step.assist.as_ref() { + let assist_ids = assist.assist_ids.clone(); + cx.window_context().defer(move |cx| { + InlineAssistant::update_global(cx, |assistant, cx| { + for assist_id in assist_ids { + assistant.finish_assist(assist_id, false, cx); + } + }) + }); + } + } + } + + fn reject_workflow_step(&mut self, range: Range, cx: &mut ViewContext) { + if let Some(workflow_step) = self.workflow_steps.get_mut(&range) { + if let Some(assist) = workflow_step.assist.take() { + cx.window_context().defer(move |cx| { + InlineAssistant::update_global(cx, |assistant, cx| { + for assist_id in assist.assist_ids { + assistant.finish_assist(assist_id, true, cx); + } + }) + }); + } } } @@ -1490,16 +1848,31 @@ impl ContextEditor { } } - fn cancel_last_assist(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { - if !self + fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { + if self .context .update(cx, |context, _| context.cancel_last_assist()) { - cx.propagate(); + return; } + + if let Some(active_step) = self.active_workflow_step() { + match active_step.status(cx) { + WorkflowStepStatus::Pending => { + self.stop_workflow_step(active_step.range.clone(), cx); + return; + } + WorkflowStepStatus::Done => { + self.reject_workflow_step(active_step.range.clone(), cx); + return; + } + _ => {} + } + } + cx.propagate(); } - fn debug_edit_steps(&mut self, _: &DebugEditSteps, cx: &mut ViewContext) { + fn debug_workflow_steps(&mut self, _: &DebugWorkflowSteps, cx: &mut ViewContext) { let mut output = String::new(); for (i, step) in self.context.read(cx).workflow_steps().iter().enumerate() { output.push_str(&format!("Step {}:\n", i + 1)); @@ -1513,14 +1886,20 @@ impl ContextEditor { .collect::() )); match &step.status { - WorkflowStepStatus::Resolved(ResolvedWorkflowStep { title, suggestions }) => { + crate::WorkflowStepStatus::Resolved(ResolvedWorkflowStep { + title, + suggestions, + }) => { output.push_str("Resolution:\n"); output.push_str(&format!(" {:?}\n", title)); output.push_str(&format!(" {:?}\n", suggestions)); } - WorkflowStepStatus::Pending(_) => { + crate::WorkflowStepStatus::Pending(_) => { output.push_str("Resolution: Pending\n"); } + crate::WorkflowStepStatus::Error(error) => { + writeln!(output, "Resolution: Error\n{:?}", error).unwrap(); + } } output.push('\n'); } @@ -1665,8 +2044,12 @@ impl ContextEditor { context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); }); } - ContextEvent::WorkflowStepsChanged => { - self.update_active_workflow_step_from_cursor(cx); + ContextEvent::WorkflowStepsRemoved(removed) => { + self.remove_workflow_steps(removed, cx); + cx.notify(); + } + ContextEvent::WorkflowStepUpdated(updated) => { + self.update_workflow_step(updated.clone(), cx); cx.notify(); } ContextEvent::SummaryChanged => { @@ -1797,6 +2180,7 @@ impl ContextEditor { height: 1, disposition: BlockDisposition::Below, render: slash_command_error_block_renderer(error_message), + priority: 0, }), None, cx, @@ -1931,31 +2315,186 @@ impl ContextEditor { } EditorEvent::SelectionsChanged { .. } => { self.scroll_position = self.cursor_scroll_position(cx); - self.update_active_workflow_step_from_cursor(cx); + self.update_active_workflow_step(cx); } _ => {} } cx.emit(event.clone()); } - fn update_active_workflow_step_from_cursor(&mut self, cx: &mut ViewContext) { - let new_step = self - .workflow_step_range_for_cursor(cx) - .as_ref() - .and_then(|step_range| { - let workflow_step = self - .context - .read(cx) - .workflow_step_for_range(step_range.clone())?; - Some(workflow_step.tagged_range.clone()) + fn active_workflow_step(&self) -> Option<&WorkflowStep> { + let step = self.active_workflow_step.as_ref()?; + self.workflow_steps.get(&step.range) + } + + fn remove_workflow_steps( + &mut self, + removed_steps: &[Range], + cx: &mut ViewContext, + ) { + let mut blocks_to_remove = HashSet::default(); + for step_range in removed_steps { + self.hide_workflow_step(step_range.clone(), cx); + if let Some(step) = self.workflow_steps.remove(step_range) { + blocks_to_remove.insert(step.header_block_id); + blocks_to_remove.insert(step.footer_block_id); + } + } + self.editor.update(cx, |editor, cx| { + editor.remove_blocks(blocks_to_remove, None, cx) + }); + self.update_active_workflow_step(cx); + } + + fn update_workflow_step( + &mut self, + step_range: Range, + cx: &mut ViewContext, + ) { + let buffer_snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx); + let (&excerpt_id, _, _) = buffer_snapshot.as_singleton().unwrap(); + + let Some(step) = self + .context + .read(cx) + .workflow_step_for_range(step_range.clone()) + else { + return; + }; + + let resolved_step = step.status.into_resolved(); + if let Some(existing_step) = self.workflow_steps.get_mut(&step_range) { + existing_step.resolved_step = resolved_step; + } else { + let start = buffer_snapshot + .anchor_in_excerpt(excerpt_id, step_range.start) + .unwrap(); + let end = buffer_snapshot + .anchor_in_excerpt(excerpt_id, step_range.end) + .unwrap(); + let weak_self = cx.view().downgrade(); + let block_ids = self.editor.update(cx, |editor, cx| { + let step_range = step_range.clone(); + let editor_focus_handle = editor.focus_handle(cx); + editor.insert_blocks( + vec![ + BlockProperties { + position: start, + height: 1, + style: BlockStyle::Sticky, + render: Box::new({ + let weak_self = weak_self.clone(); + let step_range = step_range.clone(); + move |cx| { + let current_status = weak_self + .update(&mut **cx, |context_editor, cx| { + let step = + context_editor.workflow_steps.get(&step_range)?; + Some(step.status(cx)) + }) + .ok() + .flatten(); + + let theme = cx.theme().status(); + let border_color = if current_status + .as_ref() + .map_or(false, |status| status.is_confirmed()) + { + theme.ignored_border + } else { + theme.info_border + }; + + div() + .w_full() + .px(cx.gutter_dimensions.full_width()) + .child( + h_flex() + .w_full() + .border_b_1() + .border_color(border_color) + .pb_1() + .justify_end() + .gap_2() + .children(current_status.as_ref().map(|status| { + status.into_element( + step_range.clone(), + editor_focus_handle.clone(), + weak_self.clone(), + cx, + ) + })), + ) + .into_any() + } + }), + disposition: BlockDisposition::Above, + priority: 0, + }, + BlockProperties { + position: end, + height: 0, + style: BlockStyle::Sticky, + render: Box::new(move |cx| { + let current_status = weak_self + .update(&mut **cx, |context_editor, cx| { + let step = + context_editor.workflow_steps.get(&step_range)?; + Some(step.status(cx)) + }) + .ok() + .flatten(); + let theme = cx.theme().status(); + let border_color = if current_status + .as_ref() + .map_or(false, |status| status.is_confirmed()) + { + theme.ignored_border + } else { + theme.info_border + }; + + div() + .w_full() + .px(cx.gutter_dimensions.full_width()) + .child( + h_flex().w_full().border_t_1().border_color(border_color), + ) + .into_any() + }), + disposition: BlockDisposition::Below, + priority: 0, + }, + ], + None, + cx, + ) }); - if new_step.as_ref() != self.active_workflow_step_range.as_ref() { - if let Some(old_step_range) = self.active_workflow_step_range.take() { - self.hide_workflow_step(old_step_range, cx); + self.workflow_steps.insert( + step_range.clone(), + WorkflowStep { + range: step_range.clone(), + header_block_id: block_ids[0], + footer_block_id: block_ids[1], + resolved_step, + assist: None, + }, + ); + } + + self.update_active_workflow_step(cx); + } + + fn update_active_workflow_step(&mut self, cx: &mut ViewContext) { + let new_step = self.active_workflow_step_for_cursor(cx); + if new_step.as_ref() != self.active_workflow_step.as_ref() { + if let Some(old_step) = self.active_workflow_step.take() { + self.hide_workflow_step(old_step.range, cx); } if let Some(new_step) = new_step { - self.activate_workflow_step(new_step, cx); + self.show_workflow_step(new_step.range.clone(), cx); + self.active_workflow_step = Some(new_step); } } } @@ -1965,35 +2504,30 @@ impl ContextEditor { step_range: Range, cx: &mut ViewContext, ) { - let Some(step_assist) = self.workflow_assists.get_mut(&step_range) else { + let Some(step) = self.workflow_steps.get_mut(&step_range) else { return; }; - let Some(editor) = step_assist.editor.upgrade() else { - self.workflow_assists.remove(&step_range); + let Some(assist) = step.assist.as_ref() else { + return; + }; + let Some(editor) = assist.editor.upgrade() else { return; }; - InlineAssistant::update_global(cx, |assistant, cx| { - step_assist.assist_ids.retain(|assist_id| { - match assistant.status_for_assist(*assist_id, cx) { - Some(CodegenStatus::Idle) | None => { - assistant.finish_assist(*assist_id, true, cx); - false - } - _ => true, + if matches!(step.status(cx), WorkflowStepStatus::Idle) { + let assist = step.assist.take().unwrap(); + InlineAssistant::update_global(cx, |assistant, cx| { + for assist_id in assist.assist_ids { + assistant.finish_assist(assist_id, true, cx) } }); - }); - if step_assist.assist_ids.is_empty() { - let editor_was_open = step_assist.editor_was_open; - self.workflow_assists.remove(&step_range); self.workspace .update(cx, |workspace, cx| { if let Some(pane) = workspace.pane_for(&editor) { pane.update(cx, |pane, cx| { let item_id = editor.entity_id(); - if !editor_was_open && pane.is_active_preview_item(item_id) { + if !assist.editor_was_open && pane.is_active_preview_item(item_id) { pane.close_item_by_id(item_id, SaveIntent::Skip, cx) .detach_and_log_err(cx); } @@ -2004,200 +2538,205 @@ impl ContextEditor { } } - fn activate_workflow_step( + fn show_workflow_step( &mut self, step_range: Range, cx: &mut ViewContext, - ) -> Option<()> { - if self.scroll_to_existing_workflow_assist(&step_range, cx) { - return None; - } - - let step = self - .workflow_step(&step_range, cx) - .with_context(|| format!("could not find workflow step for range {:?}", step_range)) - .log_err()?; - let Some(resolved) = step.status.as_resolved() else { - return None; + ) { + let Some(step) = self.workflow_steps.get_mut(&step_range) else { + return; }; - let title = resolved.title.clone(); - let suggestions = resolved.suggestions.clone(); - - if let Some((editor, assist_ids, editor_was_open)) = { - let assistant_panel = self.assistant_panel.upgrade()?; - if suggestions.is_empty() { - return None; - } - - let editor; - let mut editor_was_open = false; - let mut suggestion_groups = Vec::new(); - if suggestions.len() == 1 && suggestions.values().next().unwrap().len() == 1 { - // If there's only one buffer and one suggestion group, open it directly - let (buffer, groups) = suggestions.into_iter().next().unwrap(); - let group = groups.into_iter().next().unwrap(); - editor = self - .workspace - .update(cx, |workspace, cx| { - let active_pane = workspace.active_pane().clone(); - editor_was_open = - workspace.is_project_item_open::(&active_pane, &buffer, cx); - workspace.open_project_item::(active_pane, buffer, false, false, cx) - }) - .log_err()?; - - let (&excerpt_id, _, _) = editor - .read(cx) - .buffer() - .read(cx) - .read(cx) - .as_singleton() - .unwrap(); - - // Scroll the editor to the suggested assist - editor.update(cx, |editor, cx| { - let multibuffer = editor.buffer().read(cx).snapshot(cx); - let (&excerpt_id, _, buffer) = multibuffer.as_singleton().unwrap(); - let anchor = if group.context_range.start.to_offset(buffer) == 0 { - Anchor::min() - } else { - multibuffer - .anchor_in_excerpt(excerpt_id, group.context_range.start) - .unwrap() - }; - - editor.set_scroll_anchor( - ScrollAnchor { - offset: gpui::Point::default(), - anchor, - }, + let mut scroll_to_assist_id = None; + match step.status(cx) { + WorkflowStepStatus::Idle => { + if let Some(assist) = step.assist.as_ref() { + scroll_to_assist_id = assist.assist_ids.first().copied(); + } else if let Some(Ok(resolved)) = step.resolved_step.as_ref() { + step.assist = Self::open_assists_for_step( + resolved, + &self.project, + &self.assistant_panel, + &self.workspace, cx, ); - }); + } + } + WorkflowStepStatus::Pending => { + if let Some(assist) = step.assist.as_ref() { + let assistant = InlineAssistant::global(cx); + scroll_to_assist_id = assist + .assist_ids + .iter() + .copied() + .find(|assist_id| assistant.assist_status(*assist_id, cx).is_pending()); + } + } + WorkflowStepStatus::Done => { + if let Some(assist) = step.assist.as_ref() { + scroll_to_assist_id = assist.assist_ids.first().copied(); + } + } + _ => {} + } - suggestion_groups.push((excerpt_id, group)); - } else { - // If there are multiple buffers or suggestion groups, create a multibuffer - let multibuffer = cx.new_model(|cx| { - let replica_id = self.project.read(cx).replica_id(); - let mut multibuffer = - MultiBuffer::new(replica_id, Capability::ReadWrite).with_title(title); - for (buffer, groups) in suggestions { - let excerpt_ids = multibuffer.push_excerpts( - buffer, - groups.iter().map(|suggestion_group| ExcerptRange { - context: suggestion_group.context_range.clone(), - primary: None, - }), - cx, - ); - suggestion_groups.extend(excerpt_ids.into_iter().zip(groups)); - } - multibuffer - }); - - editor = cx.new_view(|cx| { - Editor::for_multibuffer(multibuffer, Some(self.project.clone()), true, cx) - }); + if let Some(assist_id) = scroll_to_assist_id { + if let Some(editor) = step + .assist + .as_ref() + .and_then(|assists| assists.editor.upgrade()) + { self.workspace .update(cx, |workspace, cx| { - workspace.add_item_to_active_pane(Box::new(editor.clone()), None, false, cx) + workspace.activate_item(&editor, false, false, cx); }) - .log_err()?; + .ok(); + InlineAssistant::update_global(cx, |assistant, cx| { + assistant.scroll_to_assist(assist_id, cx) + }); } - - let mut assist_ids = Vec::new(); - for (excerpt_id, suggestion_group) in suggestion_groups { - for suggestion in suggestion_group.suggestions { - assist_ids.extend(suggestion.show( - &editor, - excerpt_id, - &self.workspace, - &assistant_panel, - cx, - )); - } - } - - if let Some(range) = self.active_workflow_step_range.clone() { - self.workflow_assists.insert( - range, - WorkflowAssist { - assist_ids: assist_ids.clone(), - editor: editor.downgrade(), - editor_was_open, - }, - ); - } - - Some((editor, assist_ids, editor_was_open)) - } { - self.workflow_assists.insert( - step_range.clone(), - WorkflowAssist { - assist_ids, - editor_was_open, - editor: editor.downgrade(), - }, - ); } - - self.active_workflow_step_range = Some(step_range); - - Some(()) } - fn active_workflow_step<'a>(&'a self, cx: &'a AppContext) -> Option<&'a crate::WorkflowStep> { - self.active_workflow_step_range - .as_ref() - .and_then(|step_range| { - self.context - .read(cx) - .workflow_step_for_range(step_range.clone()) - }) - } - - fn workflow_step<'a>( - &'a mut self, - step_range: &Range, - cx: &'a mut ViewContext, - ) -> Option<&'a crate::WorkflowStep> { - self.context - .read(cx) - .workflow_step_for_range(step_range.clone()) - } - - fn scroll_to_existing_workflow_assist( - &self, - step_range: &Range, + fn open_assists_for_step( + resolved_step: &ResolvedWorkflowStep, + project: &Model, + assistant_panel: &WeakView, + workspace: &WeakView, cx: &mut ViewContext, - ) -> bool { - let step_assists = match self.workflow_assists.get(step_range) { - Some(assists) => assists, - None => return false, - }; - let editor = match step_assists.editor.upgrade() { - Some(editor) => editor, - None => return false, - }; - for assist_id in &step_assists.assist_ids { - match InlineAssistant::global(cx).status_for_assist(*assist_id, cx) { - Some(CodegenStatus::Idle) | None => {} - _ => { - self.workspace - .update(cx, |workspace, cx| { - workspace.activate_item(&editor, false, false, cx); - }) - .ok(); - InlineAssistant::update_global(cx, |assistant, cx| { - assistant.scroll_to_assist(*assist_id, cx) - }); - return true; + ) -> Option { + let assistant_panel = assistant_panel.upgrade()?; + if resolved_step.suggestions.is_empty() { + return None; + } + + let editor; + let mut editor_was_open = false; + let mut suggestion_groups = Vec::new(); + if resolved_step.suggestions.len() == 1 + && resolved_step.suggestions.values().next().unwrap().len() == 1 + { + // If there's only one buffer and one suggestion group, open it directly + let (buffer, groups) = resolved_step.suggestions.iter().next().unwrap(); + let group = groups.into_iter().next().unwrap(); + editor = workspace + .update(cx, |workspace, cx| { + let active_pane = workspace.active_pane().clone(); + editor_was_open = + workspace.is_project_item_open::(&active_pane, buffer, cx); + workspace.open_project_item::( + active_pane, + buffer.clone(), + false, + false, + cx, + ) + }) + .log_err()?; + + let (&excerpt_id, _, _) = editor + .read(cx) + .buffer() + .read(cx) + .read(cx) + .as_singleton() + .unwrap(); + + // Scroll the editor to the suggested assist + editor.update(cx, |editor, cx| { + let multibuffer = editor.buffer().read(cx).snapshot(cx); + let (&excerpt_id, _, buffer) = multibuffer.as_singleton().unwrap(); + let anchor = if group.context_range.start.to_offset(buffer) == 0 { + Anchor::min() + } else { + multibuffer + .anchor_in_excerpt(excerpt_id, group.context_range.start) + .unwrap() + }; + + editor.set_scroll_anchor( + ScrollAnchor { + offset: gpui::Point::default(), + anchor, + }, + cx, + ); + }); + + suggestion_groups.push((excerpt_id, group)); + } else { + // If there are multiple buffers or suggestion groups, create a multibuffer + let multibuffer = cx.new_model(|cx| { + let replica_id = project.read(cx).replica_id(); + let mut multibuffer = MultiBuffer::new(replica_id, Capability::ReadWrite) + .with_title(resolved_step.title.clone()); + for (buffer, groups) in &resolved_step.suggestions { + let excerpt_ids = multibuffer.push_excerpts( + buffer.clone(), + groups.iter().map(|suggestion_group| ExcerptRange { + context: suggestion_group.context_range.clone(), + primary: None, + }), + cx, + ); + suggestion_groups.extend(excerpt_ids.into_iter().zip(groups)); } + multibuffer + }); + + editor = cx.new_view(|cx| { + Editor::for_multibuffer(multibuffer, Some(project.clone()), true, cx) + }); + workspace + .update(cx, |workspace, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, false, cx) + }) + .log_err()?; + } + + let mut assist_ids = Vec::new(); + for (excerpt_id, suggestion_group) in suggestion_groups { + for suggestion in &suggestion_group.suggestions { + assist_ids.extend(suggestion.show( + &editor, + excerpt_id, + workspace, + &assistant_panel, + cx, + )); } } - false + + let mut observations = Vec::new(); + InlineAssistant::update_global(cx, |assistant, _cx| { + for assist_id in &assist_ids { + observations.push(assistant.observe_assist(*assist_id)); + } + }); + + Some(WorkflowAssist { + assist_ids, + editor: editor.downgrade(), + editor_was_open, + _observe_assist_status: cx.spawn(|this, mut cx| async move { + while !observations.is_empty() { + let (result, ix, _) = futures::future::select_all( + observations + .iter_mut() + .map(|observation| Box::pin(observation.changed())), + ) + .await; + + if result.is_err() { + observations.remove(ix); + } + + if this.update(&mut cx, |_, cx| cx.notify()).is_err() { + break; + } + } + }), + }) } fn handle_editor_search_event( @@ -2324,6 +2863,7 @@ impl ContextEditor { } }), disposition: BlockDisposition::Above, + priority: usize::MAX, }) .collect::>(); @@ -2583,14 +3123,15 @@ impl ContextEditor { fn render_send_button(&self, cx: &mut ViewContext) -> impl IntoElement { let focus_handle = self.focus_handle(cx).clone(); - let button_text = match self.active_workflow_step(cx) { - Some(step) => { - if step.status.is_resolved() { - "Apply Changes" - } else { - "Computing Changes..." - } - } + let button_text = match self.active_workflow_step() { + Some(step) => match step.status(cx) { + WorkflowStepStatus::Resolving => "Resolving Step...", + WorkflowStepStatus::Error(_) => "Retry Step Resolution", + WorkflowStepStatus::Idle => "Transform", + WorkflowStepStatus::Pending => "Transforming...", + WorkflowStepStatus::Done => "Accept Transformation", + WorkflowStepStatus::Confirmed => "Send", + }, None => "Send", }; @@ -2632,31 +3173,31 @@ impl ContextEditor { }) } - fn workflow_step_range_for_cursor(&self, cx: &AppContext) -> Option> { - let newest_cursor = self - .editor - .read(cx) - .selections - .newest_anchor() - .head() - .text_anchor; + fn active_workflow_step_for_cursor(&self, cx: &AppContext) -> Option { + let newest_cursor = self.editor.read(cx).selections.newest::(cx).head(); let context = self.context.read(cx); let buffer = context.buffer().read(cx); - let edit_steps = context.workflow_steps(); - edit_steps + let workflow_steps = context.workflow_steps(); + workflow_steps .binary_search_by(|step| { - let step_range = step.tagged_range.clone(); - if newest_cursor.cmp(&step_range.start, buffer).is_lt() { + let step_range = step.tagged_range.to_offset(&buffer); + if newest_cursor < step_range.start { Ordering::Greater - } else if newest_cursor.cmp(&step_range.end, buffer).is_gt() { + } else if newest_cursor > step_range.end { Ordering::Less } else { Ordering::Equal } }) .ok() - .map(|index| edit_steps[index].tagged_range.clone()) + .and_then(|index| { + let range = workflow_steps[index].tagged_range.clone(); + Some(ActiveWorkflowStep { + resolved: self.workflow_steps.get(&range)?.resolved_step.is_some(), + range, + }) + }) } } @@ -2667,14 +3208,14 @@ impl Render for ContextEditor { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { v_flex() .key_context("ContextEditor") - .capture_action(cx.listener(ContextEditor::cancel_last_assist)) + .capture_action(cx.listener(ContextEditor::cancel)) .capture_action(cx.listener(ContextEditor::save)) .capture_action(cx.listener(ContextEditor::copy)) .capture_action(cx.listener(ContextEditor::cycle_message_role)) .capture_action(cx.listener(ContextEditor::confirm_command)) .on_action(cx.listener(ContextEditor::assist)) .on_action(cx.listener(ContextEditor::split)) - .on_action(cx.listener(ContextEditor::debug_edit_steps)) + .on_action(cx.listener(ContextEditor::debug_workflow_steps)) .size_full() .children(self.render_notice(cx)) .child( diff --git a/crates/assistant/src/context.rs b/crates/assistant/src/context.rs index 33a8961800..b9b3564900 100644 --- a/crates/assistant/src/context.rs +++ b/crates/assistant/src/context.rs @@ -284,7 +284,8 @@ pub enum ContextEvent { AssistError(String), MessagesEdited, SummaryChanged, - WorkflowStepsChanged, + WorkflowStepsRemoved(Vec>), + WorkflowStepUpdated(Range), StreamedCompletion, PendingSlashCommandsUpdated { removed: Vec>, @@ -360,22 +361,17 @@ pub struct ResolvedWorkflowStep { pub enum WorkflowStepStatus { Pending(Task>), Resolved(ResolvedWorkflowStep), + Error(Arc), } impl WorkflowStepStatus { - pub fn as_resolved(&self) -> Option<&ResolvedWorkflowStep> { + pub fn into_resolved(&self) -> Option>> { match self { - WorkflowStepStatus::Resolved(suggestions) => Some(suggestions), + WorkflowStepStatus::Resolved(resolved) => Some(Ok(resolved.clone())), + WorkflowStepStatus::Error(error) => Some(Err(error.clone())), WorkflowStepStatus::Pending(_) => None, } } - - pub fn is_resolved(&self) -> bool { - match self { - WorkflowStepStatus::Resolved(_) => true, - WorkflowStepStatus::Pending(_) => false, - } - } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -583,12 +579,16 @@ impl WorkflowSuggestion { impl Debug for WorkflowStepStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - WorkflowStepStatus::Pending(_) => write!(f, "EditStepOperations::Pending"), + WorkflowStepStatus::Pending(_) => write!(f, "WorkflowStepStatus::Pending"), WorkflowStepStatus::Resolved(ResolvedWorkflowStep { title, suggestions }) => f - .debug_struct("EditStepOperations::Parsed") + .debug_struct("WorkflowStepStatus::Resolved") .field("title", title) .field("suggestions", suggestions) .finish(), + WorkflowStepStatus::Error(error) => f + .debug_tuple("WorkflowStepStatus::Error") + .field(error) + .finish(), } } } @@ -1058,7 +1058,7 @@ impl Context { language::Event::Edited => { self.count_remaining_tokens(cx); self.reparse_slash_commands(cx); - self.prune_invalid_edit_steps(cx); + self.prune_invalid_workflow_steps(cx); cx.emit(ContextEvent::MessagesEdited); } _ => {} @@ -1165,46 +1165,59 @@ impl Context { } } - fn prune_invalid_edit_steps(&mut self, cx: &mut ModelContext) { + fn prune_invalid_workflow_steps(&mut self, cx: &mut ModelContext) { let buffer = self.buffer.read(cx); let prev_len = self.workflow_steps.len(); + let mut removed = Vec::new(); self.workflow_steps.retain(|step| { - step.tagged_range.start.is_valid(buffer) && step.tagged_range.end.is_valid(buffer) + if step.tagged_range.start.is_valid(buffer) && step.tagged_range.end.is_valid(buffer) { + true + } else { + removed.push(step.tagged_range.clone()); + false + } }); if self.workflow_steps.len() != prev_len { - cx.emit(ContextEvent::WorkflowStepsChanged); + cx.emit(ContextEvent::WorkflowStepsRemoved(removed)); cx.notify(); } } - fn parse_edit_steps_in_range( + fn parse_workflow_steps_in_range( &mut self, range: Range, project: Model, cx: &mut ModelContext, ) { let mut new_edit_steps = Vec::new(); + let mut edits = Vec::new(); let buffer = self.buffer.read(cx).snapshot(); let mut message_lines = buffer.as_rope().chunks_in_range(range).lines(); let mut in_step = false; - let mut step_start = 0; + let mut step_open_tag_start_ix = 0; let mut line_start_offset = message_lines.offset(); while let Some(line) = message_lines.next() { if let Some(step_start_index) = line.find("") { if !in_step { in_step = true; - step_start = line_start_offset + step_start_index; + step_open_tag_start_ix = line_start_offset + step_start_index; } } if let Some(step_end_index) = line.find("") { if in_step { - let start_anchor = buffer.anchor_after(step_start); - let end_anchor = - buffer.anchor_before(line_start_offset + step_end_index + "".len()); - let tagged_range = start_anchor..end_anchor; + let step_open_tag_end_ix = step_open_tag_start_ix + "".len(); + let mut step_end_tag_start_ix = line_start_offset + step_end_index; + let step_end_tag_end_ix = step_end_tag_start_ix + "".len(); + if buffer.reversed_chars_at(step_end_tag_start_ix).next() == Some('\n') { + step_end_tag_start_ix -= 1; + } + edits.push((step_open_tag_start_ix..step_open_tag_end_ix, "")); + edits.push((step_end_tag_start_ix..step_end_tag_end_ix, "")); + let tagged_range = buffer.anchor_after(step_open_tag_end_ix) + ..buffer.anchor_before(step_end_tag_start_ix); // Check if a step with the same range already exists let existing_step_index = self @@ -1212,14 +1225,11 @@ impl Context { .binary_search_by(|probe| probe.tagged_range.cmp(&tagged_range, &buffer)); if let Err(ix) = existing_step_index { - // Step doesn't exist, so add it - let task = - self.resolve_workflow_step(tagged_range.clone(), project.clone(), cx); new_edit_steps.push(( ix, WorkflowStep { tagged_range, - status: WorkflowStepStatus::Pending(task), + status: WorkflowStepStatus::Pending(Task::ready(None)), }, )); } @@ -1231,144 +1241,176 @@ impl Context { line_start_offset = message_lines.offset(); } - // Insert new steps and generate their corresponding tasks + let mut updated = Vec::new(); for (index, step) in new_edit_steps.into_iter().rev() { + let step_range = step.tagged_range.clone(); + updated.push(step_range.clone()); self.workflow_steps.insert(index, step); + self.resolve_workflow_step(step_range, project.clone(), cx); } - - cx.emit(ContextEvent::WorkflowStepsChanged); - cx.notify(); + self.buffer + .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); } - fn resolve_workflow_step( - &self, + pub fn resolve_workflow_step( + &mut self, tagged_range: Range, project: Model, cx: &mut ModelContext, - ) -> Task> { - let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else { - return Task::ready(Err(anyhow!("no active model")).log_err()); + ) { + let Ok(step_index) = self + .workflow_steps + .binary_search_by(|step| step.tagged_range.cmp(&tagged_range, self.buffer.read(cx))) + else { + return; }; let mut request = self.to_completion_request(cx); - let step_text = self - .buffer - .read(cx) - .text_for_range(tagged_range.clone()) - .collect::(); + let Some(edit_step) = self.workflow_steps.get_mut(step_index) else { + return; + }; - cx.spawn(|this, mut cx| { - async move { - let mut prompt = this.update(&mut cx, |this, _| { - this.prompt_builder.generate_step_resolution_prompt() - })??; - prompt.push_str(&step_text); + if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() { + let step_text = self + .buffer + .read(cx) + .text_for_range(tagged_range.clone()) + .collect::(); - request.messages.push(LanguageModelRequestMessage { - role: Role::User, - content: prompt, - }); + let tagged_range = tagged_range.clone(); + edit_step.status = WorkflowStepStatus::Pending(cx.spawn(|this, mut cx| { + async move { + let result = async { + let mut prompt = this.update(&mut cx, |this, _| { + this.prompt_builder.generate_step_resolution_prompt() + })??; + prompt.push_str(&step_text); - // Invoke the model to get its edit suggestions for this workflow step. - let resolution = model - .use_tool::(request, &cx) - .await?; - - // Translate the parsed suggestions to our internal types, which anchor the suggestions to locations in the code. - let suggestion_tasks: Vec<_> = resolution - .suggestions - .iter() - .map(|suggestion| suggestion.resolve(project.clone(), cx.clone())) - .collect(); - - // Expand the context ranges of each suggestion and group suggestions with overlapping context ranges. - let suggestions = future::join_all(suggestion_tasks) - .await - .into_iter() - .filter_map(|task| task.log_err()) - .collect::>(); - - let mut suggestions_by_buffer = HashMap::default(); - for (buffer, suggestion) in suggestions { - suggestions_by_buffer - .entry(buffer) - .or_insert_with(Vec::new) - .push(suggestion); - } - - let mut suggestion_groups_by_buffer = HashMap::default(); - for (buffer, mut suggestions) in suggestions_by_buffer { - let mut suggestion_groups = Vec::::new(); - let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?; - // Sort suggestions by their range so that earlier, larger ranges come first - suggestions.sort_by(|a, b| a.range().cmp(&b.range(), &snapshot)); - - // Merge overlapping suggestions - suggestions.dedup_by(|a, b| b.try_merge(&a, &snapshot)); - - // Create context ranges for each suggestion - for suggestion in suggestions { - let context_range = { - let suggestion_point_range = suggestion.range().to_point(&snapshot); - let start_row = suggestion_point_range.start.row.saturating_sub(5); - let end_row = cmp::min( - suggestion_point_range.end.row + 5, - snapshot.max_point().row, - ); - let start = snapshot.anchor_before(Point::new(start_row, 0)); - let end = snapshot - .anchor_after(Point::new(end_row, snapshot.line_len(end_row))); - start..end - }; - - if let Some(last_group) = suggestion_groups.last_mut() { - if last_group - .context_range - .end - .cmp(&context_range.start, &snapshot) - .is_ge() - { - // Merge with the previous group if context ranges overlap - last_group.context_range.end = context_range.end; - last_group.suggestions.push(suggestion); - } else { - // Create a new group - suggestion_groups.push(WorkflowSuggestionGroup { - context_range, - suggestions: vec![suggestion], - }); - } - } else { - // Create the first group - suggestion_groups.push(WorkflowSuggestionGroup { - context_range, - suggestions: vec![suggestion], - }); - } - } - - suggestion_groups_by_buffer.insert(buffer, suggestion_groups); - } - - this.update(&mut cx, |this, cx| { - let step_index = this - .workflow_steps - .binary_search_by(|step| { - step.tagged_range.cmp(&tagged_range, this.buffer.read(cx)) - }) - .map_err(|_| anyhow!("edit step not found"))?; - if let Some(edit_step) = this.workflow_steps.get_mut(step_index) { - edit_step.status = WorkflowStepStatus::Resolved(ResolvedWorkflowStep { - title: resolution.step_title, - suggestions: suggestion_groups_by_buffer, + request.messages.push(LanguageModelRequestMessage { + role: Role::User, + content: prompt, }); - cx.emit(ContextEvent::WorkflowStepsChanged); - } - anyhow::Ok(()) - })? - } - .log_err() - }) + + // Invoke the model to get its edit suggestions for this workflow step. + let resolution = model + .use_tool::(request, &cx) + .await?; + + // Translate the parsed suggestions to our internal types, which anchor the suggestions to locations in the code. + let suggestion_tasks: Vec<_> = resolution + .suggestions + .iter() + .map(|suggestion| suggestion.resolve(project.clone(), cx.clone())) + .collect(); + + // Expand the context ranges of each suggestion and group suggestions with overlapping context ranges. + let suggestions = future::join_all(suggestion_tasks) + .await + .into_iter() + .filter_map(|task| task.log_err()) + .collect::>(); + + let mut suggestions_by_buffer = HashMap::default(); + for (buffer, suggestion) in suggestions { + suggestions_by_buffer + .entry(buffer) + .or_insert_with(Vec::new) + .push(suggestion); + } + + let mut suggestion_groups_by_buffer = HashMap::default(); + for (buffer, mut suggestions) in suggestions_by_buffer { + let mut suggestion_groups = Vec::::new(); + let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?; + // Sort suggestions by their range so that earlier, larger ranges come first + suggestions.sort_by(|a, b| a.range().cmp(&b.range(), &snapshot)); + + // Merge overlapping suggestions + suggestions.dedup_by(|a, b| b.try_merge(&a, &snapshot)); + + // Create context ranges for each suggestion + for suggestion in suggestions { + let context_range = { + let suggestion_point_range = + suggestion.range().to_point(&snapshot); + let start_row = + suggestion_point_range.start.row.saturating_sub(5); + let end_row = cmp::min( + suggestion_point_range.end.row + 5, + snapshot.max_point().row, + ); + let start = snapshot.anchor_before(Point::new(start_row, 0)); + let end = snapshot.anchor_after(Point::new( + end_row, + snapshot.line_len(end_row), + )); + start..end + }; + + if let Some(last_group) = suggestion_groups.last_mut() { + if last_group + .context_range + .end + .cmp(&context_range.start, &snapshot) + .is_ge() + { + // Merge with the previous group if context ranges overlap + last_group.context_range.end = context_range.end; + last_group.suggestions.push(suggestion); + } else { + // Create a new group + suggestion_groups.push(WorkflowSuggestionGroup { + context_range, + suggestions: vec![suggestion], + }); + } + } else { + // Create the first group + suggestion_groups.push(WorkflowSuggestionGroup { + context_range, + suggestions: vec![suggestion], + }); + } + } + + suggestion_groups_by_buffer.insert(buffer, suggestion_groups); + } + + Ok((resolution.step_title, suggestion_groups_by_buffer)) + }; + + let result = result.await; + this.update(&mut cx, |this, cx| { + let step_index = this + .workflow_steps + .binary_search_by(|step| { + step.tagged_range.cmp(&tagged_range, this.buffer.read(cx)) + }) + .map_err(|_| anyhow!("edit step not found"))?; + if let Some(edit_step) = this.workflow_steps.get_mut(step_index) { + edit_step.status = match result { + Ok((title, suggestions)) => { + WorkflowStepStatus::Resolved(ResolvedWorkflowStep { + title, + suggestions, + }) + } + Err(error) => WorkflowStepStatus::Error(Arc::new(error)), + }; + cx.emit(ContextEvent::WorkflowStepUpdated(tagged_range)); + cx.notify(); + } + anyhow::Ok(()) + })? + } + .log_err() + })); + } else { + edit_step.status = WorkflowStepStatus::Error(Arc::new(anyhow!("no active model"))); + } + + cx.emit(ContextEvent::WorkflowStepUpdated(tagged_range)); + cx.notify(); } pub fn pending_command_for_position( @@ -1587,7 +1629,7 @@ impl Context { message_start_offset..message_new_end_offset }); if let Some(project) = this.project.clone() { - this.parse_edit_steps_in_range(message_range, project, cx); + this.parse_workflow_steps_in_range(message_range, project, cx); } cx.emit(ContextEvent::StreamedCompletion); @@ -3011,13 +3053,13 @@ mod tests { vec![ ( Point::new(response_start_row + 2, 0) - ..Point::new(response_start_row + 14, 7), - WorkflowStepEditSuggestionStatus::Pending + ..Point::new(response_start_row + 13, 3), + WorkflowStepTestStatus::Pending ), ( - Point::new(response_start_row + 16, 0) - ..Point::new(response_start_row + 28, 7), - WorkflowStepEditSuggestionStatus::Pending + Point::new(response_start_row + 15, 0) + ..Point::new(response_start_row + 26, 3), + WorkflowStepTestStatus::Pending ), ] ); @@ -3041,45 +3083,45 @@ mod tests { // Wait for tool use to be processed. cx.run_until_parked(); - // Verify that the last edit step is not pending anymore. + // Verify that the first edit step is not pending anymore. context.read_with(cx, |context, cx| { assert_eq!( workflow_steps(context, cx), vec![ ( Point::new(response_start_row + 2, 0) - ..Point::new(response_start_row + 14, 7), - WorkflowStepEditSuggestionStatus::Pending + ..Point::new(response_start_row + 13, 3), + WorkflowStepTestStatus::Resolved ), ( - Point::new(response_start_row + 16, 0) - ..Point::new(response_start_row + 28, 7), - WorkflowStepEditSuggestionStatus::Resolved + Point::new(response_start_row + 15, 0) + ..Point::new(response_start_row + 26, 3), + WorkflowStepTestStatus::Pending ), ] ); }); #[derive(Copy, Clone, Debug, Eq, PartialEq)] - enum WorkflowStepEditSuggestionStatus { + enum WorkflowStepTestStatus { Pending, Resolved, + Error, } fn workflow_steps( context: &Context, cx: &AppContext, - ) -> Vec<(Range, WorkflowStepEditSuggestionStatus)> { + ) -> Vec<(Range, WorkflowStepTestStatus)> { context .workflow_steps .iter() .map(|step| { let buffer = context.buffer.read(cx); let status = match &step.status { - WorkflowStepStatus::Pending(_) => WorkflowStepEditSuggestionStatus::Pending, - WorkflowStepStatus::Resolved { .. } => { - WorkflowStepEditSuggestionStatus::Resolved - } + WorkflowStepStatus::Pending(_) => WorkflowStepTestStatus::Pending, + WorkflowStepStatus::Resolved { .. } => WorkflowStepTestStatus::Resolved, + WorkflowStepStatus::Error(_) => WorkflowStepTestStatus::Error, }; (step.tagged_range.to_point(buffer), status) }) diff --git a/crates/assistant/src/inline_assistant.rs b/crates/assistant/src/inline_assistant.rs index 27d05f7deb..a6e71fc40b 100644 --- a/crates/assistant/src/inline_assistant.rs +++ b/crates/assistant/src/inline_assistant.rs @@ -68,6 +68,9 @@ pub struct InlineAssistant { assists: HashMap, assists_by_editor: HashMap, EditorInlineAssists>, assist_groups: HashMap, + assist_observations: + HashMap, async_watch::Receiver<()>)>, + confirmed_assists: HashMap>, prompt_history: VecDeque, prompt_builder: Arc, telemetry: Option>, @@ -88,6 +91,8 @@ impl InlineAssistant { assists: HashMap::default(), assists_by_editor: HashMap::default(), assist_groups: HashMap::default(), + assist_observations: HashMap::default(), + confirmed_assists: HashMap::default(), prompt_history: VecDeque::default(), prompt_builder, telemetry: Some(telemetry), @@ -343,6 +348,7 @@ impl InlineAssistant { height: prompt_editor_height, render: build_assist_editor_renderer(prompt_editor), disposition: BlockDisposition::Above, + priority: 0, }, BlockProperties { style: BlockStyle::Sticky, @@ -357,6 +363,7 @@ impl InlineAssistant { .into_any_element() }), disposition: BlockDisposition::Below, + priority: 0, }, ]; @@ -654,8 +661,21 @@ impl InlineAssistant { if undo { assist.codegen.update(cx, |codegen, cx| codegen.undo(cx)); + } else { + self.confirmed_assists.insert(assist_id, assist.codegen); } } + + // Remove the assist from the status updates map + self.assist_observations.remove(&assist_id); + } + + pub fn undo_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool { + let Some(codegen) = self.confirmed_assists.remove(&assist_id) else { + return false; + }; + codegen.update(cx, |this, cx| this.undo(cx)); + true } fn dismiss_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool { @@ -854,6 +874,10 @@ impl InlineAssistant { ) }) .log_err(); + + if let Some((tx, _)) = self.assist_observations.get(&assist_id) { + tx.send(()).ok(); + } } pub fn stop_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) { @@ -864,19 +888,24 @@ impl InlineAssistant { }; assist.codegen.update(cx, |codegen, cx| codegen.stop(cx)); + + if let Some((tx, _)) = self.assist_observations.get(&assist_id) { + tx.send(()).ok(); + } } - pub fn status_for_assist( - &self, - assist_id: InlineAssistId, - cx: &WindowContext, - ) -> Option { - let assist = self.assists.get(&assist_id)?; - match &assist.codegen.read(cx).status { - CodegenStatus::Idle => Some(CodegenStatus::Idle), - CodegenStatus::Pending => Some(CodegenStatus::Pending), - CodegenStatus::Done => Some(CodegenStatus::Done), - CodegenStatus::Error(error) => Some(CodegenStatus::Error(anyhow!("{:?}", error))), + pub fn assist_status(&self, assist_id: InlineAssistId, cx: &AppContext) -> InlineAssistStatus { + if let Some(assist) = self.assists.get(&assist_id) { + match &assist.codegen.read(cx).status { + CodegenStatus::Idle => InlineAssistStatus::Idle, + CodegenStatus::Pending => InlineAssistStatus::Pending, + CodegenStatus::Done => InlineAssistStatus::Done, + CodegenStatus::Error(_) => InlineAssistStatus::Error, + } + } else if self.confirmed_assists.contains_key(&assist_id) { + InlineAssistStatus::Confirmed + } else { + InlineAssistStatus::Canceled } } @@ -1051,6 +1080,7 @@ impl InlineAssistant { .into_any_element() }), disposition: BlockDisposition::Above, + priority: 0, }); } @@ -1060,6 +1090,37 @@ impl InlineAssistant { .collect(); }) } + + pub fn observe_assist(&mut self, assist_id: InlineAssistId) -> async_watch::Receiver<()> { + if let Some((_, rx)) = self.assist_observations.get(&assist_id) { + rx.clone() + } else { + let (tx, rx) = async_watch::channel(()); + self.assist_observations.insert(assist_id, (tx, rx.clone())); + rx + } + } +} + +pub enum InlineAssistStatus { + Idle, + Pending, + Done, + Error, + Confirmed, + Canceled, +} + +impl InlineAssistStatus { + pub(crate) fn is_pending(&self) -> bool { + matches!(self, Self::Pending) + } + pub(crate) fn is_confirmed(&self) -> bool { + matches!(self, Self::Confirmed) + } + pub(crate) fn is_done(&self) -> bool { + matches!(self, Self::Done) + } } struct EditorInlineAssists { @@ -1964,6 +2025,8 @@ impl InlineAssist { if assist.decorations.is_none() { this.finish_assist(assist_id, false, cx); + } else if let Some(tx) = this.assist_observations.get(&assist_id) { + tx.0.send(()).ok(); } } }) @@ -2037,7 +2100,7 @@ pub struct Codegen { builder: Arc, } -pub enum CodegenStatus { +enum CodegenStatus { Idle, Pending, Done, diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 1078d828cc..3e35756ea4 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -449,6 +449,7 @@ impl ProjectDiagnosticsEditor { style: BlockStyle::Sticky, render: diagnostic_header_renderer(primary), disposition: BlockDisposition::Above, + priority: 0, }); } @@ -470,6 +471,7 @@ impl ProjectDiagnosticsEditor { diagnostic, None, true, true, ), disposition: BlockDisposition::Below, + priority: 0, }); } } @@ -508,6 +510,7 @@ impl ProjectDiagnosticsEditor { style: block.style, render: block.render, disposition: block.disposition, + priority: 0, }) }), Some(Autoscroll::fit()), diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index b659929ed3..0e08a9eae7 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -1281,12 +1281,14 @@ pub mod tests { position.to_point(&buffer), height ); + let priority = rng.gen_range(1..100); BlockProperties { style: BlockStyle::Fixed, position, height, disposition, render: Box::new(|_| div().into_any()), + priority: priority, } }) .collect::>(); diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index fedef3a9ae..ba84ba48ac 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -84,6 +84,7 @@ pub struct CustomBlock { style: BlockStyle, render: Arc>, disposition: BlockDisposition, + priority: usize, } pub struct BlockProperties

{ @@ -92,6 +93,7 @@ pub struct BlockProperties

{ pub style: BlockStyle, pub render: RenderBlock, pub disposition: BlockDisposition, + pub priority: usize, } impl Debug for BlockProperties

{ @@ -182,6 +184,7 @@ pub(crate) enum BlockType { pub(crate) trait BlockLike { fn block_type(&self) -> BlockType; fn disposition(&self) -> BlockDisposition; + fn priority(&self) -> usize; } #[allow(clippy::large_enum_variant)] @@ -215,6 +218,14 @@ impl BlockLike for Block { fn disposition(&self) -> BlockDisposition { self.disposition() } + + fn priority(&self) -> usize { + match self { + Block::Custom(block) => block.priority, + Block::ExcerptHeader { .. } => usize::MAX, + Block::ExcerptFooter { .. } => 0, + } + } } impl Block { @@ -660,7 +671,10 @@ impl BlockMap { (BlockType::Header, BlockType::Header) => Ordering::Equal, (BlockType::Header, _) => Ordering::Less, (_, BlockType::Header) => Ordering::Greater, - (BlockType::Custom(a_id), BlockType::Custom(b_id)) => a_id.cmp(&b_id), + (BlockType::Custom(a_id), BlockType::Custom(b_id)) => block_b + .priority() + .cmp(&block_a.priority()) + .then_with(|| a_id.cmp(&b_id)), }) }) }); @@ -802,6 +816,7 @@ impl<'a> BlockMapWriter<'a> { render: Arc::new(Mutex::new(block.render)), disposition: block.disposition, style: block.style, + priority: block.priority, }); self.0.custom_blocks.insert(block_ix, new_block.clone()); self.0.custom_blocks_by_id.insert(id, new_block); @@ -832,6 +847,7 @@ impl<'a> BlockMapWriter<'a> { style: block.style, render: block.render.clone(), disposition: block.disposition, + priority: block.priority, }; let new_block = Arc::new(new_block); *block = new_block.clone(); @@ -1463,6 +1479,7 @@ mod tests { height: 1, disposition: BlockDisposition::Above, render: Box::new(|_| div().into_any()), + priority: 0, }, BlockProperties { style: BlockStyle::Fixed, @@ -1470,6 +1487,7 @@ mod tests { height: 2, disposition: BlockDisposition::Above, render: Box::new(|_| div().into_any()), + priority: 0, }, BlockProperties { style: BlockStyle::Fixed, @@ -1477,6 +1495,7 @@ mod tests { height: 3, disposition: BlockDisposition::Below, render: Box::new(|_| div().into_any()), + priority: 0, }, ]); @@ -1716,6 +1735,7 @@ mod tests { height: 1, disposition: BlockDisposition::Above, render: Box::new(|_| div().into_any()), + priority: 0, }, BlockProperties { style: BlockStyle::Fixed, @@ -1723,6 +1743,7 @@ mod tests { height: 2, disposition: BlockDisposition::Above, render: Box::new(|_| div().into_any()), + priority: 0, }, BlockProperties { style: BlockStyle::Fixed, @@ -1730,6 +1751,7 @@ mod tests { height: 3, disposition: BlockDisposition::Below, render: Box::new(|_| div().into_any()), + priority: 0, }, ]); @@ -1819,6 +1841,7 @@ mod tests { disposition: BlockDisposition::Above, render: Box::new(|_| div().into_any()), height: 1, + priority: 0, }, BlockProperties { style: BlockStyle::Fixed, @@ -1826,6 +1849,7 @@ mod tests { disposition: BlockDisposition::Below, render: Box::new(|_| div().into_any()), height: 1, + priority: 0, }, ]); @@ -1924,6 +1948,7 @@ mod tests { height, disposition, render: Box::new(|_| div().into_any()), + priority: 0, } }) .collect::>(); @@ -1944,6 +1969,7 @@ mod tests { style: props.style, render: Box::new(|_| div().into_any()), disposition: props.disposition, + priority: 0, })); for (block_id, props) in block_ids.into_iter().zip(block_properties) { custom_blocks.push((block_id, props)); @@ -2014,6 +2040,7 @@ mod tests { disposition: block.disposition, id: *id, height: block.height, + priority: block.priority, }, ) })); @@ -2235,6 +2262,7 @@ mod tests { disposition: BlockDisposition, id: CustomBlockId, height: u32, + priority: usize, }, } @@ -2250,6 +2278,14 @@ mod tests { fn disposition(&self) -> BlockDisposition { self.disposition() } + + fn priority(&self) -> usize { + match self { + ExpectedBlock::Custom { priority, .. } => *priority, + ExpectedBlock::ExcerptHeader { .. } => usize::MAX, + ExpectedBlock::ExcerptFooter { .. } => 0, + } + } } impl ExpectedBlock { @@ -2277,6 +2313,7 @@ mod tests { id: block.id, disposition: block.disposition, height: block.height, + priority: block.priority, }, Block::ExcerptHeader { height, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6d973e29e4..8df833120a 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -9614,6 +9614,7 @@ impl Editor { } }), disposition: BlockDisposition::Below, + priority: 0, }], Some(Autoscroll::fit()), cx, @@ -9877,6 +9878,7 @@ impl Editor { height: message_height, render: diagnostic_block_renderer(diagnostic, None, true, true), disposition: BlockDisposition::Below, + priority: 0, } }), cx, @@ -10182,6 +10184,7 @@ impl Editor { if let Some(autoscroll) = autoscroll { self.request_autoscroll(autoscroll, cx); } + cx.notify(); blocks } @@ -10196,6 +10199,7 @@ impl Editor { if let Some(autoscroll) = autoscroll { self.request_autoscroll(autoscroll, cx); } + cx.notify(); } pub fn replace_blocks( @@ -10208,9 +10212,8 @@ impl Editor { .update(cx, |display_map, _cx| display_map.replace_blocks(renderers)); if let Some(autoscroll) = autoscroll { self.request_autoscroll(autoscroll, cx); - } else { - cx.notify(); } + cx.notify(); } pub fn remove_blocks( @@ -10225,6 +10228,7 @@ impl Editor { if let Some(autoscroll) = autoscroll { self.request_autoscroll(autoscroll, cx); } + cx.notify(); } pub fn row_for_block( diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index ba563c26ea..bdff24131b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -3785,6 +3785,7 @@ fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) { disposition: BlockDisposition::Below, height: 1, render: Box::new(|_| div().into_any()), + priority: 0, }], Some(Autoscroll::fit()), cx, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 8a386baebf..29033646b7 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -6478,6 +6478,7 @@ mod tests { height: 3, position: Anchor::min(), render: Box::new(|cx| div().h(3. * cx.line_height()).into_any()), + priority: 0, }], None, cx, diff --git a/crates/editor/src/hunk_diff.rs b/crates/editor/src/hunk_diff.rs index f4f9c0f8fb..2c4438eb3b 100644 --- a/crates/editor/src/hunk_diff.rs +++ b/crates/editor/src/hunk_diff.rs @@ -525,6 +525,7 @@ impl Editor { .child(editor_with_deleted_text.clone()) .into_any_element() }), + priority: 0, }), None, cx, diff --git a/crates/repl/src/session.rs b/crates/repl/src/session.rs index 4fb981d85c..ba1d70020c 100644 --- a/crates/repl/src/session.rs +++ b/crates/repl/src/session.rs @@ -87,6 +87,7 @@ impl EditorBlock { style: BlockStyle::Sticky, render: Self::create_output_area_renderer(execution_view.clone(), on_close.clone()), disposition: BlockDisposition::Below, + priority: 0, }; let block_id = editor.insert_blocks([block], None, cx)[0]; diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index de2557f176..3da874c04b 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -50,6 +50,7 @@ pub enum TintColor { Accent, Negative, Warning, + Positive, } impl TintColor { @@ -73,6 +74,12 @@ impl TintColor { label_color: cx.theme().colors().text, icon_color: cx.theme().colors().text, }, + TintColor::Positive => ButtonLikeStyles { + background: cx.theme().status().success_background, + border_color: cx.theme().status().success_border, + label_color: cx.theme().colors().text, + icon_color: cx.theme().colors().text, + }, } } } @@ -83,6 +90,7 @@ impl From for Color { TintColor::Accent => Color::Accent, TintColor::Negative => Color::Error, TintColor::Warning => Color::Warning, + TintColor::Positive => Color::Success, } } } diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 9e87e8046a..40b51f616d 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -256,6 +256,7 @@ pub enum IconName { TextSearch, Trash, TriangleRight, + Undo, Update, WholeWord, XCircle, @@ -419,6 +420,7 @@ impl IconName { IconName::Trash => "icons/trash.svg", IconName::TriangleRight => "icons/triangle_right.svg", IconName::Update => "icons/update.svg", + IconName::Undo => "icons/undo.svg", IconName::WholeWord => "icons/word_search.svg", IconName::XCircle => "icons/error.svg", IconName::ZedAssistant => "icons/zed_assistant.svg",