agent: Add animation in the edit file tool card until a diff is assigned (#29773)

Max Brunsfeld and Danilo Leal created

This PR prevents this edit card from being shown expanded but empty,
like this:

<img width="590" alt="Screenshot 2025-05-01 at 7 38 47 PM"
src="https://github.com/user-attachments/assets/147d3d73-05b9-4493-8145-0ee915f12cd9"
/>

Now, we will show an animation until it has a diff computed.


https://github.com/user-attachments/assets/52900cdf-ee3d-4c3b-88c7-c53377543bcf

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

crates/assistant_tools/src/edit_file_tool.rs | 213 +++++++++++++--------
1 file changed, 135 insertions(+), 78 deletions(-)

Detailed changes

crates/assistant_tools/src/edit_file_tool.rs 🔗

@@ -7,7 +7,8 @@ use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolUse
 use buffer_diff::{BufferDiff, BufferDiffSnapshot};
 use editor::{Editor, EditorMode, MultiBuffer, PathKey};
 use gpui::{
-    AnyWindowHandle, App, AppContext, AsyncApp, Context, Entity, EntityId, Task, WeakEntity,
+    Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Context, Entity, EntityId,
+    Task, WeakEntity, pulsating_between,
 };
 use language::{
     Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Rope, TextBuffer,
@@ -20,6 +21,7 @@ use serde::{Deserialize, Serialize};
 use std::{
     path::{Path, PathBuf},
     sync::Arc,
+    time::Duration,
 };
 use ui::{Disclosure, Tooltip, Window, prelude::*};
 use util::ResultExt;
@@ -323,6 +325,10 @@ impl EditFileToolCard {
         }
     }
 
+    pub fn has_diff(&self) -> bool {
+        self.total_lines.is_some()
+    }
+
     pub fn set_diff(
         &mut self,
         path: Arc<Path>,
@@ -463,45 +469,44 @@ impl ToolCard for EditFileToolCard {
             .rounded_t_md()
             .when(!failed, |header| header.bg(codeblock_header_bg))
             .child(path_label_button)
-            .map(|container| {
-                if failed {
-                    container.child(
-                        h_flex()
-                            .gap_1()
-                            .child(
-                                Icon::new(IconName::Close)
-                                    .size(IconSize::Small)
-                                    .color(Color::Error),
-                            )
-                            .child(
-                                Disclosure::new(
-                                    ("edit-file-error-disclosure", self.editor_unique_id),
-                                    self.error_expanded,
-                                )
-                                .opened_icon(IconName::ChevronUp)
-                                .closed_icon(IconName::ChevronDown)
-                                .on_click(cx.listener(
-                                    move |this, _event, _window, _cx| {
-                                        this.error_expanded = !this.error_expanded;
-                                    },
-                                )),
-                            ),
-                    )
-                } else {
-                    container.child(
-                        Disclosure::new(
-                            ("edit-file-disclosure", self.editor_unique_id),
-                            self.preview_expanded,
+            .when(failed, |header| {
+                header.child(
+                    h_flex()
+                        .gap_1()
+                        .child(
+                            Icon::new(IconName::Close)
+                                .size(IconSize::Small)
+                                .color(Color::Error),
                         )
-                        .opened_icon(IconName::ChevronUp)
-                        .closed_icon(IconName::ChevronDown)
-                        .on_click(cx.listener(
-                            move |this, _event, _window, _cx| {
-                                this.preview_expanded = !this.preview_expanded;
-                            },
-                        )),
+                        .child(
+                            Disclosure::new(
+                                ("edit-file-error-disclosure", self.editor_unique_id),
+                                self.error_expanded,
+                            )
+                            .opened_icon(IconName::ChevronUp)
+                            .closed_icon(IconName::ChevronDown)
+                            .on_click(cx.listener(
+                                move |this, _event, _window, _cx| {
+                                    this.error_expanded = !this.error_expanded;
+                                },
+                            )),
+                        ),
+                )
+            })
+            .when(!failed && self.has_diff(), |header| {
+                header.child(
+                    Disclosure::new(
+                        ("edit-file-disclosure", self.editor_unique_id),
+                        self.preview_expanded,
                     )
-                }
+                    .opened_icon(IconName::ChevronUp)
+                    .closed_icon(IconName::ChevronDown)
+                    .on_click(cx.listener(
+                        move |this, _event, _window, _cx| {
+                            this.preview_expanded = !this.preview_expanded;
+                        },
+                    )),
+                )
             });
 
         let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
@@ -538,6 +543,50 @@ impl ToolCard for EditFileToolCard {
         const DEFAULT_COLLAPSED_LINES: u32 = 10;
         let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
 
+        let waiting_for_diff = {
+            let styles = [
+                ("w_4_5", (0.1, 0.85), 2000),
+                ("w_1_4", (0.2, 0.75), 2200),
+                ("w_2_4", (0.15, 0.64), 1900),
+                ("w_3_5", (0.25, 0.72), 2300),
+                ("w_2_5", (0.3, 0.56), 1800),
+            ];
+
+            let mut container = v_flex()
+                .p_3()
+                .gap_1p5()
+                .border_t_1()
+                .border_color(border_color)
+                .bg(cx.theme().colors().editor_background);
+
+            for (width_method, pulse_range, duration_ms) in styles.iter() {
+                let (min_opacity, max_opacity) = *pulse_range;
+                let placeholder = match *width_method {
+                    "w_4_5" => div().w_3_4(),
+                    "w_1_4" => div().w_1_4(),
+                    "w_2_4" => div().w_2_4(),
+                    "w_3_5" => div().w_3_5(),
+                    "w_2_5" => div().w_2_5(),
+                    _ => div().w_1_2(),
+                }
+                .id("loading_div")
+                .h_2()
+                .rounded_full()
+                .bg(cx.theme().colors().element_active)
+                .with_animation(
+                    "loading_pulsate",
+                    Animation::new(Duration::from_millis(*duration_ms))
+                        .repeat()
+                        .with_easing(pulsating_between(min_opacity, max_opacity)),
+                    |label, delta| label.opacity(delta),
+                );
+
+                container = container.child(placeholder);
+            }
+
+            container
+        };
+
         v_flex()
             .mb_2()
             .border_1()
@@ -573,50 +622,58 @@ impl ToolCard for EditFileToolCard {
                         ),
                 )
             })
-            .when(!failed && self.preview_expanded, |card| {
-                card.child(
-                    v_flex()
-                        .relative()
-                        .h_full()
-                        .when(!self.full_height_expanded, |editor_container| {
-                            editor_container
-                                .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
-                        })
-                        .overflow_hidden()
-                        .border_t_1()
-                        .border_color(border_color)
-                        .bg(cx.theme().colors().editor_background)
-                        .child(div().pl_1().child(editor))
-                        .when(
-                            !self.full_height_expanded && is_collapsible,
-                            |editor_container| editor_container.child(gradient_overlay),
-                        ),
-                )
-                .when(is_collapsible, |editor_container| {
-                    editor_container.child(
-                        h_flex()
-                            .id(("expand-button", self.editor_unique_id))
-                            .flex_none()
-                            .cursor_pointer()
-                            .h_5()
-                            .justify_center()
-                            .rounded_b_md()
+            .when(!self.has_diff() && !failed, |card| {
+                card.child(waiting_for_diff)
+            })
+            .when(
+                !failed && self.preview_expanded && self.has_diff(),
+                |card| {
+                    card.child(
+                        v_flex()
+                            .relative()
+                            .h_full()
+                            .when(!self.full_height_expanded, |editor_container| {
+                                editor_container
+                                    .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
+                            })
+                            .overflow_hidden()
                             .border_t_1()
                             .border_color(border_color)
                             .bg(cx.theme().colors().editor_background)
-                            .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
-                            .child(
-                                Icon::new(full_height_icon)
-                                    .size(IconSize::Small)
-                                    .color(Color::Muted),
-                            )
-                            .tooltip(Tooltip::text(full_height_tooltip_label))
-                            .on_click(cx.listener(move |this, _event, _window, _cx| {
-                                this.full_height_expanded = !this.full_height_expanded;
-                            })),
+                            .child(div().pl_1().child(editor))
+                            .when(
+                                !self.full_height_expanded && is_collapsible,
+                                |editor_container| editor_container.child(gradient_overlay),
+                            ),
                     )
-                })
-            })
+                    .when(is_collapsible, |editor_container| {
+                        editor_container.child(
+                            h_flex()
+                                .id(("expand-button", self.editor_unique_id))
+                                .flex_none()
+                                .cursor_pointer()
+                                .h_5()
+                                .justify_center()
+                                .rounded_b_md()
+                                .border_t_1()
+                                .border_color(border_color)
+                                .bg(cx.theme().colors().editor_background)
+                                .hover(|style| {
+                                    style.bg(cx.theme().colors().element_hover.opacity(0.1))
+                                })
+                                .child(
+                                    Icon::new(full_height_icon)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
+                                .tooltip(Tooltip::text(full_height_tooltip_label))
+                                .on_click(cx.listener(move |this, _event, _window, _cx| {
+                                    this.full_height_expanded = !this.full_height_expanded;
+                                })),
+                        )
+                    })
+                },
+            )
     }
 }