Add a workflow step resolution view (#16315)

Max Brunsfeld and Nathan created

You can now click on a step header (the words `Step 3`, etc) to open a
new tab containing a dedicated view for the resolution of that step.
This view looks similar to a context editor, and has sections for the
step input, the streaming tool output, and the interpreted results.

Hitting `cmd-enter` in this view re-resolves the step.


https://github.com/user-attachments/assets/64d82cdb-e70f-4204-8697-b30df5a645d5



Release Notes:

- N/A

---------

Co-authored-by: Nathan <nathan@zed.dev>

Change summary

crates/assistant/src/assistant_panel.rs    |  84 +++++-
crates/assistant/src/context.rs            |  34 +-
crates/assistant/src/context_inspector.rs  |  17 
crates/assistant/src/workflow.rs           | 160 ++++++++++---
crates/assistant/src/workflow/step_view.rs | 290 ++++++++++++++++++++++++
crates/language/src/language.rs            |   2 
crates/language/src/outline.rs             |  17 
7 files changed, 525 insertions(+), 79 deletions(-)

Detailed changes

crates/assistant/src/assistant_panel.rs 🔗

@@ -15,7 +15,7 @@ use crate::{
     DebugWorkflowSteps, DeployHistory, DeployPromptLibrary, InlineAssist, InlineAssistId,
     InlineAssistant, InsertIntoEditor, MessageStatus, ModelSelector, PendingSlashCommand,
     PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata, ResolvedWorkflowStep,
-    SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector,
+    SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector, WorkflowStepView,
 };
 use crate::{ContextStoreEvent, ShowConfiguration};
 use anyhow::{anyhow, Result};
@@ -36,10 +36,10 @@ use fs::Fs;
 use gpui::{
     canvas, div, img, percentage, point, pulsating_between, size, Action, Animation, AnimationExt,
     AnyElement, AnyView, AppContext, AsyncWindowContext, ClipboardEntry, ClipboardItem,
-    Context as _, DismissEvent, Empty, Entity, EntityId, EventEmitter, FocusHandle, FocusableView,
-    FontWeight, InteractiveElement, IntoElement, Model, ParentElement, Pixels, ReadGlobal, Render,
-    RenderImage, SharedString, Size, StatefulInteractiveElement, Styled, Subscription, Task,
-    Transformation, UpdateGlobal, View, VisualContext, WeakView, WindowContext,
+    Context as _, CursorStyle, DismissEvent, Empty, Entity, EntityId, EventEmitter, FocusHandle,
+    FocusableView, FontWeight, InteractiveElement, IntoElement, Model, ParentElement, Pixels,
+    ReadGlobal, Render, RenderImage, SharedString, Size, StatefulInteractiveElement, Styled,
+    Subscription, Task, Transformation, UpdateGlobal, View, VisualContext, WeakView, WindowContext,
 };
 use indexed_docs::IndexedDocsStore;
 use language::{
@@ -59,7 +59,7 @@ use std::{
     borrow::Cow,
     cmp::{self, Ordering},
     fmt::Write,
-    ops::Range,
+    ops::{DerefMut, Range},
     path::PathBuf,
     sync::Arc,
     time::Duration,
@@ -1388,7 +1388,7 @@ impl WorkflowStep {
     fn status(&self, cx: &AppContext) -> WorkflowStepStatus {
         match self.resolved_step.as_ref() {
             Some(Ok(step)) => {
-                if step.suggestions.is_empty() {
+                if step.suggestion_groups.is_empty() {
                     WorkflowStepStatus::Empty
                 } else if let Some(assist) = self.assist.as_ref() {
                     let assistant = InlineAssistant::global(cx);
@@ -2030,7 +2030,10 @@ impl ContextEditor {
                     .collect::<String>()
             ));
             match &step.resolution.read(cx).result {
-                Some(Ok(ResolvedWorkflowStep { title, suggestions })) => {
+                Some(Ok(ResolvedWorkflowStep {
+                    title,
+                    suggestion_groups: suggestions,
+                })) => {
                     output.push_str("Resolution:\n");
                     output.push_str(&format!("  {:?}\n", title));
                     output.push_str(&format!("  {:?}\n", suggestions));
@@ -2571,16 +2574,33 @@ impl ContextEditor {
                                         })
                                         .unwrap_or_default();
                                     let step_label = if let Some(index) = step_index {
-
                                         Label::new(format!("Step {index}")).size(LabelSize::Small)
-                                        } else {
-                                            Label::new("Step").size(LabelSize::Small)
-                                        };
+                                    } else {
+                                        Label::new("Step").size(LabelSize::Small)
+                                    };
+
                                     let step_label = if current_status.as_ref().is_some_and(|status| status.is_confirmed()) {
                                         h_flex().items_center().gap_2().child(step_label.strikethrough(true).color(Color::Muted)).child(Icon::new(IconName::Check).size(IconSize::Small).color(Color::Created))
                                     } else {
                                         div().child(step_label)
                                     };
+
+                                    let step_label = step_label.id("step")
+                                        .cursor(CursorStyle::PointingHand)
+                                        .on_click({
+                                            let this = weak_self.clone();
+                                            let step_range = step_range.clone();
+                                            move |_, cx| {
+                                                this
+                                                    .update(cx, |this, cx| {
+                                                        this.open_workflow_step(
+                                                            step_range.clone(), cx,
+                                                        );
+                                                    })
+                                                    .ok();
+                                            }
+                                        });
+
                                     div()
                                         .w_full()
                                         .px(cx.gutter_dimensions.full_width())
@@ -2699,6 +2719,30 @@ impl ContextEditor {
         self.update_active_workflow_step(cx);
     }
 
+    fn open_workflow_step(
+        &mut self,
+        step_range: Range<language::Anchor>,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<()> {
+        let pane = self
+            .assistant_panel
+            .update(cx, |panel, _| panel.pane())
+            .ok()??;
+        let context = self.context.read(cx);
+        let language_registry = context.language_registry();
+        let step = context.workflow_step_for_range(step_range)?;
+        let resolution = step.resolution.clone();
+        let view = cx.new_view(|cx| {
+            WorkflowStepView::new(self.context.clone(), resolution, language_registry, cx)
+        });
+        cx.deref_mut().defer(move |cx| {
+            pane.update(cx, |pane, cx| {
+                pane.add_item(Box::new(view), true, true, None, cx);
+            });
+        });
+        None
+    }
+
     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() {
@@ -2820,18 +2864,24 @@ impl ContextEditor {
         cx: &mut ViewContext<Self>,
     ) -> Option<WorkflowAssist> {
         let assistant_panel = assistant_panel.upgrade()?;
-        if resolved_step.suggestions.is_empty() {
+        if resolved_step.suggestion_groups.is_empty() {
             return None;
         }
 
         let editor;
         let mut editor_was_open = false;
         let mut suggestion_groups = Vec::new();
-        if resolved_step.suggestions.len() == 1
-            && resolved_step.suggestions.values().next().unwrap().len() == 1
+        if resolved_step.suggestion_groups.len() == 1
+            && resolved_step
+                .suggestion_groups
+                .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 (buffer, groups) = resolved_step.suggestion_groups.iter().next().unwrap();
             let group = groups.into_iter().next().unwrap();
             editor = workspace
                 .update(cx, |workspace, cx| {
@@ -2884,7 +2934,7 @@ impl ContextEditor {
                 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 {
+                for (buffer, groups) in &resolved_step.suggestion_groups {
                     let excerpt_ids = multibuffer.push_excerpts(
                         buffer.clone(),
                         groups.iter().map(|suggestion_group| ExcerptRange {

crates/assistant/src/context.rs 🔗

@@ -838,6 +838,10 @@ impl Context {
         &self.buffer
     }
 
+    pub fn language_registry(&self) -> Arc<LanguageRegistry> {
+        self.language_registry.clone()
+    }
+
     pub fn project(&self) -> Option<Model<Project>> {
         self.project.clone()
     }
@@ -1073,6 +1077,7 @@ impl Context {
     }
 
     fn parse_workflow_steps_in_range(&mut self, range: Range<usize>, cx: &mut ModelContext<Self>) {
+        let weak_self = cx.weak_model();
         let mut new_edit_steps = Vec::new();
         let mut edits = Vec::new();
 
@@ -1116,7 +1121,10 @@ impl Context {
                             ix,
                             WorkflowStep {
                                 resolution: cx.new_model(|_| {
-                                    WorkflowStepResolution::new(tagged_range.clone())
+                                    WorkflowStepResolution::new(
+                                        tagged_range.clone(),
+                                        weak_self.clone(),
+                                    )
                                 }),
                                 tagged_range,
                                 _task: None,
@@ -1161,21 +1169,21 @@ impl Context {
         cx.emit(ContextEvent::WorkflowStepUpdated(tagged_range.clone()));
         cx.notify();
 
-        let task = self.workflow_steps[step_index]
-            .resolution
-            .update(cx, |resolution, cx| resolution.resolve(self, cx));
-        self.workflow_steps[step_index]._task = task.map(|task| {
-            cx.spawn(|this, mut cx| async move {
-                task.await;
-                this.update(&mut cx, |_, cx| {
-                    cx.emit(ContextEvent::WorkflowStepUpdated(tagged_range));
-                    cx.notify();
-                })
-                .ok();
-            })
+        let resolution = self.workflow_steps[step_index].resolution.clone();
+        cx.defer(move |cx| {
+            resolution.update(cx, |resolution, cx| resolution.resolve(cx));
         });
     }
 
+    pub fn workflow_step_updated(
+        &mut self,
+        range: Range<language::Anchor>,
+        cx: &mut ModelContext<Self>,
+    ) {
+        cx.emit(ContextEvent::WorkflowStepUpdated(range));
+        cx.notify();
+    }
+
     pub fn pending_command_for_position(
         &mut self,
         position: language::Anchor,

crates/assistant/src/context_inspector.rs 🔗

@@ -54,7 +54,10 @@ impl ContextInspector {
         let step = context.read(cx).workflow_step_for_range(range)?;
         let mut output = String::from("\n\n");
         match &step.resolution.read(cx).result {
-            Some(Ok(ResolvedWorkflowStep { title, suggestions })) => {
+            Some(Ok(ResolvedWorkflowStep {
+                title,
+                suggestion_groups: suggestions,
+            })) => {
                 writeln!(output, "Resolution:").ok()?;
                 writeln!(output, "  {title:?}").ok()?;
                 if suggestions.is_empty() {
@@ -189,27 +192,31 @@ fn pretty_print_workflow_suggestion(
 ) {
     use std::fmt::Write;
     let (position, description, range) = match &suggestion.kind {
-        WorkflowSuggestionKind::Update { range, description } => {
-            (None, Some(description), Some(range))
-        }
+        WorkflowSuggestionKind::Update {
+            range, description, ..
+        } => (None, Some(description), Some(range)),
         WorkflowSuggestionKind::CreateFile { description } => (None, Some(description), None),
         WorkflowSuggestionKind::AppendChild {
             position,
             description,
+            ..
         } => (Some(position), Some(description), None),
         WorkflowSuggestionKind::InsertSiblingBefore {
             position,
             description,
+            ..
         } => (Some(position), Some(description), None),
         WorkflowSuggestionKind::InsertSiblingAfter {
             position,
             description,
+            ..
         } => (Some(position), Some(description), None),
         WorkflowSuggestionKind::PrependChild {
             position,
             description,
+            ..
         } => (Some(position), Some(description), None),
-        WorkflowSuggestionKind::Delete { range } => (None, None, Some(range)),
+        WorkflowSuggestionKind::Delete { range, .. } => (None, None, Some(range)),
     };
     writeln!(out, "    Tool input: {}", suggestion.tool_input).ok();
     writeln!(

crates/assistant/src/workflow.rs 🔗

@@ -1,3 +1,5 @@
+mod step_view;
+
 use crate::{
     prompts::StepResolutionContext, AssistantPanel, Context, InlineAssistId, InlineAssistant,
 };
@@ -5,8 +7,11 @@ use anyhow::{anyhow, Error, Result};
 use collections::HashMap;
 use editor::Editor;
 use futures::future;
-use gpui::{Model, ModelContext, Task, UpdateGlobal as _, View, WeakView, WindowContext};
-use language::{Anchor, Buffer, BufferSnapshot};
+use gpui::{
+    AppContext, Model, ModelContext, Task, UpdateGlobal as _, View, WeakModel, WeakView,
+    WindowContext,
+};
+use language::{Anchor, Buffer, BufferSnapshot, SymbolPath};
 use language_model::{LanguageModelRegistry, LanguageModelRequestMessage, Role};
 use project::Project;
 use rope::Point;
@@ -17,16 +22,20 @@ use text::{AnchorRangeExt as _, OffsetRangeExt as _};
 use util::ResultExt as _;
 use workspace::Workspace;
 
+pub use step_view::WorkflowStepView;
+
 pub struct WorkflowStepResolution {
     tagged_range: Range<Anchor>,
     output: String,
+    context: WeakModel<Context>,
+    resolve_task: Option<Task<()>>,
     pub result: Option<Result<ResolvedWorkflowStep, Arc<Error>>>,
 }
 
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub struct ResolvedWorkflowStep {
     pub title: String,
-    pub suggestions: HashMap<Model<Buffer>, Vec<WorkflowSuggestionGroup>>,
+    pub suggestion_groups: HashMap<Model<Buffer>, Vec<WorkflowSuggestionGroup>>,
 }
 
 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -67,6 +76,7 @@ impl WorkflowSuggestion {
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub enum WorkflowSuggestionKind {
     Update {
+        symbol_path: SymbolPath,
         range: Range<language::Anchor>,
         description: String,
     },
@@ -74,48 +84,63 @@ pub enum WorkflowSuggestionKind {
         description: String,
     },
     InsertSiblingBefore {
+        symbol_path: SymbolPath,
         position: language::Anchor,
         description: String,
     },
     InsertSiblingAfter {
+        symbol_path: SymbolPath,
         position: language::Anchor,
         description: String,
     },
     PrependChild {
+        symbol_path: Option<SymbolPath>,
         position: language::Anchor,
         description: String,
     },
     AppendChild {
+        symbol_path: Option<SymbolPath>,
         position: language::Anchor,
         description: String,
     },
     Delete {
+        symbol_path: SymbolPath,
         range: Range<language::Anchor>,
     },
 }
 
 impl WorkflowStepResolution {
-    pub fn new(range: Range<Anchor>) -> Self {
+    pub fn new(range: Range<Anchor>, context: WeakModel<Context>) -> Self {
         Self {
             tagged_range: range,
             output: String::new(),
+            context,
             result: None,
+            resolve_task: None,
         }
     }
 
-    pub fn resolve(
-        &mut self,
-        context: &Context,
-        cx: &mut ModelContext<WorkflowStepResolution>,
-    ) -> Option<Task<()>> {
+    pub fn step_text(&self, context: &Context, cx: &AppContext) -> String {
+        context
+            .buffer()
+            .clone()
+            .read(cx)
+            .text_for_range(self.tagged_range.clone())
+            .collect::<String>()
+    }
+
+    pub fn resolve(&mut self, cx: &mut ModelContext<WorkflowStepResolution>) -> Option<()> {
+        let range = self.tagged_range.clone();
+        let context = self.context.upgrade()?;
+        let context = context.read(cx);
         let project = context.project()?;
-        let context_buffer = context.buffer().clone();
         let prompt_builder = context.prompt_builder();
         let mut request = context.to_completion_request(cx);
         let model = LanguageModelRegistry::read_global(cx).active_model();
+        let context_buffer = context.buffer();
         let step_text = context_buffer
             .read(cx)
-            .text_for_range(self.tagged_range.clone())
+            .text_for_range(range.clone())
             .collect::<String>();
 
         let mut workflow_context = String::new();
@@ -127,7 +152,7 @@ impl WorkflowStepResolution {
             write!(&mut workflow_context, "</message>").unwrap();
         }
 
-        Some(cx.spawn(|this, mut cx| async move {
+        self.resolve_task = Some(cx.spawn(|this, mut cx| async move {
             let result = async {
                 let Some(model) = model else {
                     return Err(anyhow!("no model selected"));
@@ -136,6 +161,7 @@ impl WorkflowStepResolution {
                 this.update(&mut cx, |this, cx| {
                     this.output.clear();
                     this.result = None;
+                    this.result_updated(cx);
                     cx.notify();
                 })?;
 
@@ -167,6 +193,11 @@ impl WorkflowStepResolution {
                     serde_json::from_str::<tool::WorkflowStepResolutionTool>(&this.output)
                 })??;
 
+                this.update(&mut cx, |this, cx| {
+                    this.output = serde_json::to_string_pretty(&resolution).unwrap();
+                    cx.notify();
+                })?;
+
                 // Translate the parsed suggestions to our internal types, which anchor the suggestions to locations in the code.
                 let suggestion_tasks: Vec<_> = resolution
                     .suggestions
@@ -251,13 +282,28 @@ impl WorkflowStepResolution {
             let result = result.await;
             this.update(&mut cx, |this, cx| {
                 this.result = Some(match result {
-                    Ok((title, suggestions)) => Ok(ResolvedWorkflowStep { title, suggestions }),
+                    Ok((title, suggestion_groups)) => Ok(ResolvedWorkflowStep {
+                        title,
+                        suggestion_groups,
+                    }),
                     Err(error) => Err(Arc::new(error)),
                 });
+                this.context
+                    .update(cx, |context, cx| context.workflow_step_updated(range, cx))
+                    .ok();
                 cx.notify();
             })
             .ok();
-        }))
+        }));
+        None
+    }
+
+    fn result_updated(&mut self, cx: &mut ModelContext<Self>) {
+        self.context
+            .update(cx, |context, cx| {
+                context.workflow_step_updated(self.tagged_range.clone(), cx)
+            })
+            .ok();
     }
 }
 
@@ -270,7 +316,7 @@ impl WorkflowSuggestionKind {
             | Self::InsertSiblingAfter { position, .. }
             | Self::PrependChild { position, .. }
             | Self::AppendChild { position, .. } => *position..*position,
-            Self::Delete { range } => range.clone(),
+            Self::Delete { range, .. } => range.clone(),
         }
     }
 
@@ -298,6 +344,30 @@ impl WorkflowSuggestionKind {
         }
     }
 
+    fn symbol_path(&self) -> Option<&SymbolPath> {
+        match self {
+            Self::Update { symbol_path, .. } => Some(symbol_path),
+            Self::InsertSiblingBefore { symbol_path, .. } => Some(symbol_path),
+            Self::InsertSiblingAfter { symbol_path, .. } => Some(symbol_path),
+            Self::PrependChild { symbol_path, .. } => symbol_path.as_ref(),
+            Self::AppendChild { symbol_path, .. } => symbol_path.as_ref(),
+            Self::Delete { symbol_path, .. } => Some(symbol_path),
+            Self::CreateFile { .. } => None,
+        }
+    }
+
+    fn kind(&self) -> &str {
+        match self {
+            Self::Update { .. } => "Update",
+            Self::CreateFile { .. } => "CreateFile",
+            Self::InsertSiblingBefore { .. } => "InsertSiblingBefore",
+            Self::InsertSiblingAfter { .. } => "InsertSiblingAfter",
+            Self::PrependChild { .. } => "PrependChild",
+            Self::AppendChild { .. } => "AppendChild",
+            Self::Delete { .. } => "Delete",
+        }
+    }
+
     fn try_merge(&mut self, other: &Self, buffer: &BufferSnapshot) -> bool {
         let range = self.range();
         let other_range = other.range();
@@ -333,7 +403,9 @@ impl WorkflowSuggestionKind {
         let snapshot = buffer.read(cx).snapshot(cx);
 
         match self {
-            Self::Update { range, description } => {
+            Self::Update {
+                range, description, ..
+            } => {
                 initial_prompt = description.clone();
                 suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
                     ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
@@ -345,6 +417,7 @@ impl WorkflowSuggestionKind {
             Self::InsertSiblingBefore {
                 position,
                 description,
+                ..
             } => {
                 let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
                 initial_prompt = description.clone();
@@ -361,6 +434,7 @@ impl WorkflowSuggestionKind {
             Self::InsertSiblingAfter {
                 position,
                 description,
+                ..
             } => {
                 let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
                 initial_prompt = description.clone();
@@ -377,6 +451,7 @@ impl WorkflowSuggestionKind {
             Self::PrependChild {
                 position,
                 description,
+                ..
             } => {
                 let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
                 initial_prompt = description.clone();
@@ -393,6 +468,7 @@ impl WorkflowSuggestionKind {
             Self::AppendChild {
                 position,
                 description,
+                ..
             } => {
                 let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
                 initial_prompt = description.clone();
@@ -406,7 +482,7 @@ impl WorkflowSuggestionKind {
                     line_start..line_start
                 });
             }
-            Self::Delete { range } => {
+            Self::Delete { range, .. } => {
                 initial_prompt = "Delete".to_string();
                 suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
                     ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
@@ -528,10 +604,10 @@ pub mod tool {
                     symbol,
                     description,
                 } => {
-                    let symbol = outline
+                    let (symbol_path, symbol) = outline
                         .find_most_similar(&symbol)
-                        .with_context(|| format!("symbol not found: {:?}", symbol))?
-                        .to_point(&snapshot);
+                        .with_context(|| format!("symbol not found: {:?}", symbol))?;
+                    let symbol = symbol.to_point(&snapshot);
                     let start = symbol
                         .annotation_range
                         .map_or(symbol.range.start, |range| range.start);
@@ -541,7 +617,11 @@ pub mod tool {
                         snapshot.line_len(symbol.range.end.row),
                     );
                     let range = snapshot.anchor_before(start)..snapshot.anchor_after(end);
-                    WorkflowSuggestionKind::Update { range, description }
+                    WorkflowSuggestionKind::Update {
+                        range,
+                        description,
+                        symbol_path,
+                    }
                 }
                 WorkflowSuggestionToolKind::Create { description } => {
                     WorkflowSuggestionKind::CreateFile { description }
@@ -550,10 +630,10 @@ pub mod tool {
                     symbol,
                     description,
                 } => {
-                    let symbol = outline
+                    let (symbol_path, symbol) = outline
                         .find_most_similar(&symbol)
-                        .with_context(|| format!("symbol not found: {:?}", symbol))?
-                        .to_point(&snapshot);
+                        .with_context(|| format!("symbol not found: {:?}", symbol))?;
+                    let symbol = symbol.to_point(&snapshot);
                     let position = snapshot.anchor_before(
                         symbol
                             .annotation_range
@@ -564,20 +644,22 @@ pub mod tool {
                     WorkflowSuggestionKind::InsertSiblingBefore {
                         position,
                         description,
+                        symbol_path,
                     }
                 }
                 WorkflowSuggestionToolKind::InsertSiblingAfter {
                     symbol,
                     description,
                 } => {
-                    let symbol = outline
+                    let (symbol_path, symbol) = outline
                         .find_most_similar(&symbol)
-                        .with_context(|| format!("symbol not found: {:?}", symbol))?
-                        .to_point(&snapshot);
+                        .with_context(|| format!("symbol not found: {:?}", symbol))?;
+                    let symbol = symbol.to_point(&snapshot);
                     let position = snapshot.anchor_after(symbol.range.end);
                     WorkflowSuggestionKind::InsertSiblingAfter {
                         position,
                         description,
+                        symbol_path,
                     }
                 }
                 WorkflowSuggestionToolKind::PrependChild {
@@ -585,10 +667,10 @@ pub mod tool {
                     description,
                 } => {
                     if let Some(symbol) = symbol {
-                        let symbol = outline
+                        let (symbol_path, symbol) = outline
                             .find_most_similar(&symbol)
-                            .with_context(|| format!("symbol not found: {:?}", symbol))?
-                            .to_point(&snapshot);
+                            .with_context(|| format!("symbol not found: {:?}", symbol))?;
+                        let symbol = symbol.to_point(&snapshot);
 
                         let position = snapshot.anchor_after(
                             symbol
@@ -598,11 +680,13 @@ pub mod tool {
                         WorkflowSuggestionKind::PrependChild {
                             position,
                             description,
+                            symbol_path: Some(symbol_path),
                         }
                     } else {
                         WorkflowSuggestionKind::PrependChild {
                             position: language::Anchor::MIN,
                             description,
+                            symbol_path: None,
                         }
                     }
                 }
@@ -611,10 +695,10 @@ pub mod tool {
                     description,
                 } => {
                     if let Some(symbol) = symbol {
-                        let symbol = outline
+                        let (symbol_path, symbol) = outline
                             .find_most_similar(&symbol)
-                            .with_context(|| format!("symbol not found: {:?}", symbol))?
-                            .to_point(&snapshot);
+                            .with_context(|| format!("symbol not found: {:?}", symbol))?;
+                        let symbol = symbol.to_point(&snapshot);
 
                         let position = snapshot.anchor_before(
                             symbol
@@ -624,19 +708,21 @@ pub mod tool {
                         WorkflowSuggestionKind::AppendChild {
                             position,
                             description,
+                            symbol_path: Some(symbol_path),
                         }
                     } else {
                         WorkflowSuggestionKind::PrependChild {
                             position: language::Anchor::MAX,
                             description,
+                            symbol_path: None,
                         }
                     }
                 }
                 WorkflowSuggestionToolKind::Delete { symbol } => {
-                    let symbol = outline
+                    let (symbol_path, symbol) = outline
                         .find_most_similar(&symbol)
-                        .with_context(|| format!("symbol not found: {:?}", symbol))?
-                        .to_point(&snapshot);
+                        .with_context(|| format!("symbol not found: {:?}", symbol))?;
+                    let symbol = symbol.to_point(&snapshot);
                     let start = symbol
                         .annotation_range
                         .map_or(symbol.range.start, |range| range.start);
@@ -646,7 +732,7 @@ pub mod tool {
                         snapshot.line_len(symbol.range.end.row),
                     );
                     let range = snapshot.anchor_before(start)..snapshot.anchor_after(end);
-                    WorkflowSuggestionKind::Delete { range }
+                    WorkflowSuggestionKind::Delete { range, symbol_path }
                 }
             };
 

crates/assistant/src/workflow/step_view.rs 🔗

@@ -0,0 +1,290 @@
+use super::WorkflowStepResolution;
+use crate::{Assist, Context};
+use editor::{
+    display_map::{BlockDisposition, BlockProperties, BlockStyle},
+    Editor, EditorEvent, ExcerptRange, MultiBuffer,
+};
+use gpui::{
+    div, AnyElement, AppContext, Context as _, Empty, EventEmitter, FocusableView, IntoElement,
+    Model, ParentElement as _, Render, SharedString, Styled as _, View, ViewContext,
+    VisualContext as _, WeakModel, WindowContext,
+};
+use language::{language_settings::SoftWrap, Anchor, Buffer, LanguageRegistry};
+use std::{ops::DerefMut, sync::Arc};
+use theme::ActiveTheme as _;
+use ui::{
+    h_flex, v_flex, ButtonCommon as _, ButtonLike, ButtonStyle, Color, InteractiveElement as _,
+    Label, LabelCommon as _,
+};
+use workspace::{
+    item::{self, Item},
+    pane,
+    searchable::SearchableItemHandle,
+};
+
+pub struct WorkflowStepView {
+    step: WeakModel<WorkflowStepResolution>,
+    tool_output_buffer: Model<Buffer>,
+    editor: View<Editor>,
+}
+
+impl WorkflowStepView {
+    pub fn new(
+        context: Model<Context>,
+        step: Model<WorkflowStepResolution>,
+        language_registry: Arc<LanguageRegistry>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let tool_output_buffer = cx.new_model(|cx| Buffer::local(step.read(cx).output.clone(), cx));
+        let buffer = cx.new_model(|cx| {
+            let mut buffer = MultiBuffer::without_headers(0, language::Capability::ReadWrite);
+            buffer.push_excerpts(
+                context.read(cx).buffer().clone(),
+                [ExcerptRange {
+                    context: step.read(cx).tagged_range.clone(),
+                    primary: None,
+                }],
+                cx,
+            );
+            buffer.push_excerpts(
+                tool_output_buffer.clone(),
+                [ExcerptRange {
+                    context: Anchor::MIN..Anchor::MAX,
+                    primary: None,
+                }],
+                cx,
+            );
+            buffer
+        });
+
+        let buffer_snapshot = buffer.read(cx).snapshot(cx);
+        let output_excerpt = buffer_snapshot.excerpts().skip(1).next().unwrap().0;
+        let input_start_anchor = multi_buffer::Anchor::min();
+        let output_start_anchor = buffer_snapshot
+            .anchor_in_excerpt(output_excerpt, Anchor::MIN)
+            .unwrap();
+        let output_end_anchor = multi_buffer::Anchor::max();
+
+        let handle = cx.view().downgrade();
+        let editor = cx.new_view(|cx| {
+            let mut editor = Editor::for_multibuffer(buffer.clone(), None, false, cx);
+            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
+            editor.set_show_line_numbers(false, cx);
+            editor.set_show_git_diff_gutter(false, cx);
+            editor.set_show_code_actions(false, cx);
+            editor.set_show_runnables(false, cx);
+            editor.set_show_wrap_guides(false, cx);
+            editor.set_show_indent_guides(false, cx);
+            editor.set_read_only(true);
+            editor.insert_blocks(
+                [
+                    BlockProperties {
+                        position: input_start_anchor,
+                        height: 1,
+                        style: BlockStyle::Fixed,
+                        render: Box::new(|cx| section_header("Step Input", cx)),
+                        disposition: BlockDisposition::Above,
+                        priority: 0,
+                    },
+                    BlockProperties {
+                        position: output_start_anchor,
+                        height: 1,
+                        style: BlockStyle::Fixed,
+                        render: Box::new(|cx| section_header("Tool Output", cx)),
+                        disposition: BlockDisposition::Above,
+                        priority: 0,
+                    },
+                    BlockProperties {
+                        position: output_end_anchor,
+                        height: 1,
+                        style: BlockStyle::Fixed,
+                        render: Box::new(move |cx| {
+                            if let Some(result) = handle.upgrade().and_then(|this| {
+                                this.update(cx.deref_mut(), |this, cx| this.render_result(cx))
+                            }) {
+                                v_flex()
+                                    .child(section_header("Output", cx))
+                                    .child(
+                                        div().pl(cx.gutter_dimensions.full_width()).child(result),
+                                    )
+                                    .into_any_element()
+                            } else {
+                                Empty.into_any_element()
+                            }
+                        }),
+                        disposition: BlockDisposition::Below,
+                        priority: 0,
+                    },
+                ],
+                None,
+                cx,
+            );
+            editor
+        });
+
+        cx.observe(&step, Self::step_updated).detach();
+        cx.observe_release(&step, Self::step_released).detach();
+
+        cx.spawn(|this, mut cx| async move {
+            if let Ok(language) = language_registry.language_for_name("JSON").await {
+                this.update(&mut cx, |this, cx| {
+                    this.tool_output_buffer.update(cx, |buffer, cx| {
+                        buffer.set_language(Some(language), cx);
+                    });
+                })
+                .ok();
+            }
+        })
+        .detach();
+
+        Self {
+            tool_output_buffer,
+            step: step.downgrade(),
+            editor,
+        }
+    }
+
+    fn render_result(&mut self, cx: &mut ViewContext<Self>) -> Option<AnyElement> {
+        let step = self.step.upgrade()?;
+        let result = step.read(cx).result.as_ref()?;
+        match result {
+            Ok(result) => Some(
+                v_flex()
+                    .child(result.title.clone())
+                    .children(result.suggestion_groups.iter().filter_map(
+                        |(buffer, suggestion_groups)| {
+                            let path = buffer.read(cx).file().map(|f| f.path());
+                            v_flex()
+                                .mb_2()
+                                .border_b_1()
+                                .children(path.map(|path| format!("path: {}", path.display())))
+                                .children(suggestion_groups.iter().map(|group| {
+                                    v_flex().pl_2().children(group.suggestions.iter().map(
+                                        |suggestion| {
+                                            v_flex()
+                                                .children(
+                                                    suggestion
+                                                        .kind
+                                                        .description()
+                                                        .map(|desc| format!("description: {desc}")),
+                                                )
+                                                .child(format!("kind: {}", suggestion.kind.kind()))
+                                                .children(
+                                                    suggestion.kind.symbol_path().map(|path| {
+                                                        format!("symbol path: {}", path.0)
+                                                    }),
+                                                )
+                                        },
+                                    ))
+                                }))
+                                .into()
+                        },
+                    ))
+                    .into_any_element(),
+            ),
+            Err(error) => Some(format!("{:?}", error).into_any_element()),
+        }
+    }
+
+    fn step_updated(&mut self, step: Model<WorkflowStepResolution>, cx: &mut ViewContext<Self>) {
+        self.tool_output_buffer.update(cx, |buffer, cx| {
+            let text = step.read(cx).output.clone();
+            buffer.set_text(text, cx);
+        });
+        cx.notify();
+    }
+
+    fn step_released(&mut self, _: &mut WorkflowStepResolution, cx: &mut ViewContext<Self>) {
+        cx.emit(EditorEvent::Closed);
+    }
+
+    fn resolve(&mut self, _: &Assist, cx: &mut ViewContext<Self>) {
+        self.step
+            .update(cx, |step, cx| {
+                step.resolve(cx);
+            })
+            .ok();
+    }
+}
+
+fn section_header(
+    name: &'static str,
+    cx: &mut editor::display_map::BlockContext,
+) -> gpui::AnyElement {
+    h_flex()
+        .pl(cx.gutter_dimensions.full_width())
+        .h_11()
+        .w_full()
+        .relative()
+        .gap_1()
+        .child(
+            ButtonLike::new("role")
+                .style(ButtonStyle::Filled)
+                .child(Label::new(name).color(Color::Default)),
+        )
+        .into_any_element()
+}
+
+impl Render for WorkflowStepView {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        div()
+            .key_context("ContextEditor")
+            .on_action(cx.listener(Self::resolve))
+            .flex_grow()
+            .bg(cx.theme().colors().editor_background)
+            .child(self.editor.clone())
+    }
+}
+
+impl EventEmitter<EditorEvent> for WorkflowStepView {}
+
+impl FocusableView for WorkflowStepView {
+    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
+        self.editor.read(cx).focus_handle(cx)
+    }
+}
+
+impl Item for WorkflowStepView {
+    type Event = EditorEvent;
+
+    fn tab_content_text(&self, _cx: &WindowContext) -> Option<SharedString> {
+        Some("workflow step".into())
+    }
+
+    fn to_item_events(event: &Self::Event, mut f: impl FnMut(item::ItemEvent)) {
+        match event {
+            EditorEvent::Edited { .. } => {
+                f(item::ItemEvent::Edit);
+            }
+            EditorEvent::TitleChanged => {
+                f(item::ItemEvent::UpdateTab);
+            }
+            EditorEvent::Closed => f(item::ItemEvent::CloseItem),
+            _ => {}
+        }
+    }
+
+    fn tab_tooltip_text(&self, _cx: &AppContext) -> Option<SharedString> {
+        None
+    }
+
+    fn as_searchable(&self, _handle: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+        None
+    }
+
+    fn set_nav_history(&mut self, nav_history: pane::ItemNavHistory, cx: &mut ViewContext<Self>) {
+        self.editor.update(cx, |editor, cx| {
+            Item::set_nav_history(editor, nav_history, cx)
+        })
+    }
+
+    fn navigate(&mut self, data: Box<dyn std::any::Any>, cx: &mut ViewContext<Self>) -> bool {
+        self.editor
+            .update(cx, |editor, cx| Item::navigate(editor, data, cx))
+    }
+
+    fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
+        self.editor
+            .update(cx, |editor, cx| Item::deactivated(editor, cx))
+    }
+}

crates/language/src/language.rs 🔗

@@ -71,7 +71,7 @@ pub use language_registry::{
     PendingLanguageServer, QUERY_FILENAME_PREFIXES,
 };
 pub use lsp::LanguageServerId;
-pub use outline::{render_item, Outline, OutlineItem};
+pub use outline::*;
 pub use syntax_map::{OwnedSyntaxLayer, SyntaxLayer};
 pub use text::{AnchorRangeExt, LineEnding};
 pub use tree_sitter::{Node, Parser, Tree, TreeCursor};

crates/language/src/outline.rs 🔗

@@ -25,6 +25,9 @@ pub struct OutlineItem<T> {
     pub annotation_range: Option<Range<T>>,
 }
 
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SymbolPath(pub String);
+
 impl<T: ToPoint> OutlineItem<T> {
     /// Converts to an equivalent outline item, but with parameterized over Points.
     pub fn to_point(&self, buffer: &BufferSnapshot) -> OutlineItem<Point> {
@@ -85,7 +88,7 @@ impl<T> Outline<T> {
     }
 
     /// Find the most similar symbol to the provided query using normalized Levenshtein distance.
-    pub fn find_most_similar(&self, query: &str) -> Option<&OutlineItem<T>> {
+    pub fn find_most_similar(&self, query: &str) -> Option<(SymbolPath, &OutlineItem<T>)> {
         const SIMILARITY_THRESHOLD: f64 = 0.6;
 
         let (position, similarity) = self
@@ -99,8 +102,10 @@ impl<T> Outline<T> {
             .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())?;
 
         if similarity >= SIMILARITY_THRESHOLD {
-            let item = self.items.get(position)?;
-            Some(item)
+            self.path_candidates
+                .get(position)
+                .map(|candidate| SymbolPath(candidate.string.clone()))
+                .zip(self.items.get(position))
         } else {
             None
         }
@@ -250,15 +255,15 @@ mod tests {
         ]);
         assert_eq!(
             outline.find_most_similar("pub fn process"),
-            Some(&outline.items[0])
+            Some((SymbolPath("fn process".into()), &outline.items[0]))
         );
         assert_eq!(
             outline.find_most_similar("async fn process"),
-            Some(&outline.items[0])
+            Some((SymbolPath("fn process".into()), &outline.items[0])),
         );
         assert_eq!(
             outline.find_most_similar("struct Processor"),
-            Some(&outline.items[1])
+            Some((SymbolPath("struct DataProcessor".into()), &outline.items[1]))
         );
         assert_eq!(outline.find_most_similar("struct User"), None);
         assert_eq!(outline.find_most_similar("struct"), None);