assistant: Polish /workflow and steps UI (#15936)

Piotr Osiewicz , Antonio Scandurra , and Antonio created

Fixes #15923
Release Notes:

- Assistant workflow steps can now be applied and reverted directly from
within the assistant panel.

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Antonio <antonio@zed.dev>

Change summary

assets/icons/undo.svg                          |    1 
crates/assistant/src/assistant.rs              |    2 
crates/assistant/src/assistant_panel.rs        | 1007 +++++++++++++++----
crates/assistant/src/context.rs                |  364 ++++---
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 
crates/ui/src/components/button/button_like.rs |    8 
crates/ui/src/components/icon.rs               |    2 
15 files changed, 1,116 insertions(+), 411 deletions(-)

Detailed changes

assets/icons/undo.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-undo"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>

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<language::Anchor>,
+    header_block_id: CustomBlockId,
+    footer_block_id: CustomBlockId,
+    resolved_step: Option<Result<ResolvedWorkflowStep, Arc<anyhow::Error>>>,
+    assist: Option<WorkflowAssist>,
+}
+
+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<anyhow::Error>),
+    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<language::Anchor>,
+        focus_handle: FocusHandle,
+        editor: WeakView<ContextEditor>,
+        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<language::Anchor>,
+    resolved: bool,
+}
+
 struct WorkflowAssist {
     editor: WeakView<Editor>,
     editor_was_open: bool,
     assist_ids: Vec<InlineAssistId>,
+    _observe_assist_status: Task<()>,
 }
 
 pub struct ContextEditor {
@@ -1346,9 +1612,9 @@ pub struct ContextEditor {
     remote_id: Option<workspace::ViewId>,
     pending_slash_command_creases: HashMap<Range<language::Anchor>, CreaseId>,
     pending_slash_command_blocks: HashMap<Range<language::Anchor>, CustomBlockId>,
-    workflow_assists: HashMap<Range<language::Anchor>, WorkflowAssist>,
-    active_workflow_step_range: Option<Range<language::Anchor>>,
     _subscriptions: Vec<Subscription>,
+    workflow_steps: HashMap<Range<language::Anchor>, WorkflowStep>,
+    active_workflow_step: Option<ActiveWorkflowStep>,
     assistant_panel: WeakView<AssistantPanel>,
     error_message: Option<SharedString>,
 }
@@ -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<Self>) {
-        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<Self>) -> 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<language::Anchor>, cx: &mut ViewContext<Self>) {
+        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
+    fn apply_active_workflow_step(&mut self, cx: &mut ViewContext<Self>) -> 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<language::Anchor>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.context.update(cx, |context, cx| {
+            context.resolve_workflow_step(range, self.project.clone(), cx)
+        });
+    }
+
+    fn stop_workflow_step(&mut self, range: Range<language::Anchor>, cx: &mut ViewContext<Self>) {
+        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<language::Anchor>, cx: &mut ViewContext<Self>) {
+        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<language::Anchor>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        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<language::Anchor>, cx: &mut ViewContext<Self>) {
+        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);
+                        }
+                    })
+                });
             }
-        } else {
-            false
         }
     }
 
@@ -1490,16 +1848,31 @@ impl ContextEditor {
         }
     }
 
-    fn cancel_last_assist(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext<Self>) {
-        if !self
+    fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext<Self>) {
+        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<Self>) {
+    fn debug_workflow_steps(&mut self, _: &DebugWorkflowSteps, cx: &mut ViewContext<Self>) {
         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::<String>()
             ));
             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<Self>) {
-        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<language::Anchor>],
+        cx: &mut ViewContext<Self>,
+    ) {
+        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<language::Anchor>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        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<Self>) {
+        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<language::Anchor>,
         cx: &mut ViewContext<Self>,
     ) {
-        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(assist) = step.assist.as_ref() else {
             return;
         };
-        let Some(editor) = step_assist.editor.upgrade() else {
-            self.workflow_assists.remove(&step_range);
+        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<language::Anchor>,
         cx: &mut ViewContext<Self>,
-    ) -> 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 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();
+                }
             }
+            _ => {}
+        }
 
-            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
+        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| {
-                        let active_pane = workspace.active_pane().clone();
-                        editor_was_open =
-                            workspace.is_project_item_open::<Editor>(&active_pane, &buffer, cx);
-                        workspace.open_project_item::<Editor>(active_pane, buffer, false, false, cx)
+                        workspace.activate_item(&editor, false, false, cx);
                     })
-                    .log_err()?;
-
-                let (&excerpt_id, _, _) = editor
-                    .read(cx)
-                    .buffer()
-                    .read(cx)
-                    .read(cx)
-                    .as_singleton()
-                    .unwrap();
+                    .ok();
+                InlineAssistant::update_global(cx, |assistant, cx| {
+                    assistant.scroll_to_assist(assist_id, cx)
+                });
+            }
+        }
+    }
 
-                // 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()
-                    };
+    fn open_assists_for_step(
+        resolved_step: &ResolvedWorkflowStep,
+        project: &Model<Project>,
+        assistant_panel: &WeakView<AssistantPanel>,
+        workspace: &WeakView<Workspace>,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<WorkflowAssist> {
+        let assistant_panel = assistant_panel.upgrade()?;
+        if resolved_step.suggestions.is_empty() {
+            return None;
+        }
 
-                    editor.set_scroll_anchor(
-                        ScrollAnchor {
-                            offset: gpui::Point::default(),
-                            anchor,
-                        },
+        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::<Editor>(&active_pane, buffer, cx);
+                    workspace.open_project_item::<Editor>(
+                        active_pane,
+                        buffer.clone(),
+                        false,
+                        false,
                         cx,
-                    );
-                });
+                    )
+                })
+                .log_err()?;
 
-                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));
-                    }
+            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 = cx.new_view(|cx| {
-                    Editor::for_multibuffer(multibuffer, Some(self.project.clone()), true, cx)
-                });
-                self.workspace
-                    .update(cx, |workspace, cx| {
-                        workspace.add_item_to_active_pane(Box::new(editor.clone()), None, false, cx)
-                    })
-                    .log_err()?;
-            }
+                editor.set_scroll_anchor(
+                    ScrollAnchor {
+                        offset: gpui::Point::default(),
+                        anchor,
+                    },
+                    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,
+            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));
                 }
-            }
-
-            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,
-                    },
-                );
-            }
+                multibuffer
+            });
 
-            Some((editor, assist_ids, editor_was_open))
-        } {
-            self.workflow_assists.insert(
-                step_range.clone(),
-                WorkflowAssist {
-                    assist_ids,
-                    editor_was_open,
-                    editor: editor.downgrade(),
-                },
-            );
+            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()?;
         }
 
-        self.active_workflow_step_range = Some(step_range);
+        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,
+                ));
+            }
+        }
 
-        Some(())
-    }
+        let mut observations = Vec::new();
+        InlineAssistant::update_global(cx, |assistant, _cx| {
+            for assist_id in &assist_ids {
+                observations.push(assistant.observe_assist(*assist_id));
+            }
+        });
 
-    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())
-            })
-    }
+        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;
 
-    fn workflow_step<'a>(
-        &'a mut self,
-        step_range: &Range<text::Anchor>,
-        cx: &'a mut ViewContext<ContextEditor>,
-    ) -> Option<&'a crate::WorkflowStep> {
-        self.context
-            .read(cx)
-            .workflow_step_for_range(step_range.clone())
-    }
+                    if result.is_err() {
+                        observations.remove(ix);
+                    }
 
-    fn scroll_to_existing_workflow_assist(
-        &self,
-        step_range: &Range<language::Anchor>,
-        cx: &mut ViewContext<Self>,
-    ) -> 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;
+                    if this.update(&mut cx, |_, cx| cx.notify()).is_err() {
+                        break;
+                    }
                 }
-            }
-        }
-        false
+            }),
+        })
     }
 
     fn handle_editor_search_event(

crates/assistant/src/context.rs 🔗

@@ -284,7 +284,8 @@ pub enum ContextEvent {
     AssistError(String),
     MessagesEdited,
     SummaryChanged,
-    WorkflowStepsChanged,
+    WorkflowStepsRemoved(Vec<Range<language::Anchor>>),
+    WorkflowStepUpdated(Range<language::Anchor>),
     StreamedCompletion,
     PendingSlashCommandsUpdated {
         removed: Vec<Range<language::Anchor>>,
@@ -360,22 +361,17 @@ pub struct ResolvedWorkflowStep {
 pub enum WorkflowStepStatus {
     Pending(Task<Option<()>>),
     Resolved(ResolvedWorkflowStep),
+    Error(Arc<anyhow::Error>),
 }
 
 impl WorkflowStepStatus {
-    pub fn as_resolved(&self) -> Option<&ResolvedWorkflowStep> {
+    pub fn into_resolved(&self) -> Option<Result<ResolvedWorkflowStep, Arc<anyhow::Error>>> {
         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<Self>) {
+    fn prune_invalid_workflow_steps(&mut self, cx: &mut ModelContext<Self>) {
         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<usize>,
         project: Model<Project>,
         cx: &mut ModelContext<Self>,
     ) {
         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("<step>") {
                 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("</step>") {
                 if in_step {
-                    let start_anchor = buffer.anchor_after(step_start);
-                    let end_anchor =
-                        buffer.anchor_before(line_start_offset + step_end_index + "</step>".len());
-                    let tagged_range = start_anchor..end_anchor;
+                    let step_open_tag_end_ix = step_open_tag_start_ix + "<step>".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 + "</step>".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<language::Anchor>,
         project: Model<Project>,
         cx: &mut ModelContext<Self>,
-    ) -> Task<Option<()>> {
-        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::<String>();
-
-        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);
+        let Some(edit_step) = self.workflow_steps.get_mut(step_index) else {
+            return;
+        };
 
-                request.messages.push(LanguageModelRequestMessage {
-                    role: Role::User,
-                    content: prompt,
-                });
+        if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() {
+            let step_text = self
+                .buffer
+                .read(cx)
+                .text_for_range(tagged_range.clone())
+                .collect::<String>();
 
-                // Invoke the model to get its edit suggestions for this workflow step.
-                let resolution = model
-                    .use_tool::<tool::WorkflowStepResolution>(request, &cx)
-                    .await?;
+            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);
+
+                        request.messages.push(LanguageModelRequestMessage {
+                            role: Role::User,
+                            content: prompt,
+                        });
 
-                // 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::<Vec<_>>();
+                        // Invoke the model to get its edit suggestions for this workflow step.
+                        let resolution = model
+                            .use_tool::<tool::WorkflowStepResolution>(request, &cx)
+                            .await?;
 
-                let mut suggestions_by_buffer = HashMap::default();
-                for (buffer, suggestion) in suggestions {
-                    suggestions_by_buffer
-                        .entry(buffer)
-                        .or_insert_with(Vec::new)
-                        .push(suggestion);
-                }
+                        // 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();
 
-                let mut suggestion_groups_by_buffer = HashMap::default();
-                for (buffer, mut suggestions) in suggestions_by_buffer {
-                    let mut suggestion_groups = Vec::<WorkflowSuggestionGroup>::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
-                        };
+                        // 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::<Vec<_>>();
+
+                        let mut suggestions_by_buffer = HashMap::default();
+                        for (buffer, suggestion) in suggestions {
+                            suggestions_by_buffer
+                                .entry(buffer)
+                                .or_insert_with(Vec::new)
+                                .push(suggestion);
+                        }
 
-                        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],
-                                });
+                        let mut suggestion_groups_by_buffer = HashMap::default();
+                        for (buffer, mut suggestions) in suggestions_by_buffer {
+                            let mut suggestion_groups = Vec::<WorkflowSuggestionGroup>::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],
+                                    });
+                                }
                             }
-                        } else {
-                            // Create the first group
-                            suggestion_groups.push(WorkflowSuggestionGroup {
-                                context_range,
-                                suggestions: vec![suggestion],
-                            });
+
+                            suggestion_groups_by_buffer.insert(buffer, suggestion_groups);
                         }
-                    }
 
-                    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")));
+        }
 
-                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,
-                        });
-                        cx.emit(ContextEvent::WorkflowStepsChanged);
-                    }
-                    anyhow::Ok(())
-                })?
-            }
-            .log_err()
-        })
+        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<Point>, WorkflowStepEditSuggestionStatus)> {
+        ) -> Vec<(Range<Point>, 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)
                 })

crates/assistant/src/inline_assistant.rs 🔗

@@ -68,6 +68,9 @@ pub struct InlineAssistant {
     assists: HashMap<InlineAssistId, InlineAssist>,
     assists_by_editor: HashMap<WeakView<Editor>, EditorInlineAssists>,
     assist_groups: HashMap<InlineAssistGroupId, InlineAssistGroup>,
+    assist_observations:
+        HashMap<InlineAssistId, (async_watch::Sender<()>, async_watch::Receiver<()>)>,
+    confirmed_assists: HashMap<InlineAssistId, Model<Codegen>>,
     prompt_history: VecDeque<String>,
     prompt_builder: Arc<PromptBuilder>,
     telemetry: Option<Arc<Telemetry>>,
@@ -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<CodegenStatus> {
-        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<PromptBuilder>,
 }
 
-pub enum CodegenStatus {
+enum CodegenStatus {
     Idle,
     Pending,
     Done,

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()),

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::<Vec<_>>();

crates/editor/src/display_map/block_map.rs 🔗

@@ -84,6 +84,7 @@ pub struct CustomBlock {
     style: BlockStyle,
     render: Arc<Mutex<RenderBlock>>,
     disposition: BlockDisposition,
+    priority: usize,
 }
 
 pub struct BlockProperties<P> {
@@ -92,6 +93,7 @@ pub struct BlockProperties<P> {
     pub style: BlockStyle,
     pub render: RenderBlock,
     pub disposition: BlockDisposition,
+    pub priority: usize,
 }
 
 impl<P: Debug> Debug for BlockProperties<P> {
@@ -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::<Vec<_>>();
@@ -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,

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(

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,

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,

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];

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<TintColor> for Color {
             TintColor::Accent => Color::Accent,
             TintColor::Negative => Color::Error,
             TintColor::Warning => Color::Warning,
+            TintColor::Positive => Color::Success,
         }
     }
 }

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",