context_pill.rs

  1use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration};
  2
  3use file_icons::FileIcons;
  4use futures::FutureExt as _;
  5use gpui::{
  6    Animation, AnimationExt as _, AnyView, ClickEvent, Entity, Image, MouseButton, Task,
  7    pulsating_between,
  8};
  9use language_model::LanguageModelImage;
 10use project::Project;
 11use prompt_store::PromptStore;
 12use rope::Point;
 13use ui::{IconButtonShape, Tooltip, prelude::*, tooltip_container};
 14use util::paths::PathStyle;
 15
 16use crate::context::{
 17    AgentContextHandle, ContextId, ContextKind, DirectoryContextHandle, FetchedUrlContext,
 18    FileContextHandle, ImageContext, ImageStatus, RulesContextHandle, SelectionContextHandle,
 19    SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle,
 20};
 21
 22#[derive(IntoElement)]
 23pub enum ContextPill {
 24    Added {
 25        context: AddedContext,
 26        dupe_name: bool,
 27        focused: bool,
 28        on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 29        on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 30    },
 31    Suggested {
 32        name: SharedString,
 33        icon_path: Option<SharedString>,
 34        kind: ContextKind,
 35        focused: bool,
 36        on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 37    },
 38}
 39
 40impl ContextPill {
 41    pub fn added(
 42        context: AddedContext,
 43        dupe_name: bool,
 44        focused: bool,
 45        on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 46    ) -> Self {
 47        Self::Added {
 48            context,
 49            dupe_name,
 50            on_remove,
 51            focused,
 52            on_click: None,
 53        }
 54    }
 55
 56    pub fn suggested(
 57        name: SharedString,
 58        icon_path: Option<SharedString>,
 59        kind: ContextKind,
 60        focused: bool,
 61    ) -> Self {
 62        Self::Suggested {
 63            name,
 64            icon_path,
 65            kind,
 66            focused,
 67            on_click: None,
 68        }
 69    }
 70
 71    pub fn on_click(mut self, listener: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>) -> Self {
 72        match &mut self {
 73            ContextPill::Added { on_click, .. } => {
 74                *on_click = Some(listener);
 75            }
 76            ContextPill::Suggested { on_click, .. } => {
 77                *on_click = Some(listener);
 78            }
 79        }
 80        self
 81    }
 82
 83    pub fn id(&self) -> ElementId {
 84        match self {
 85            Self::Added { context, .. } => context.handle.element_id("context-pill".into()),
 86            Self::Suggested { .. } => "suggested-context-pill".into(),
 87        }
 88    }
 89
 90    pub fn icon(&self) -> Icon {
 91        match self {
 92            Self::Suggested {
 93                icon_path: Some(icon_path),
 94                ..
 95            } => Icon::from_path(icon_path),
 96            Self::Suggested { kind, .. } => Icon::new(kind.icon()),
 97            Self::Added { context, .. } => context.icon(),
 98        }
 99    }
100}
101
102impl RenderOnce for ContextPill {
103    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
104        let color = cx.theme().colors();
105
106        let base_pill = h_flex()
107            .id(self.id())
108            .pl_1()
109            .pb(px(1.))
110            .border_1()
111            .rounded_sm()
112            .gap_1()
113            .child(self.icon().size(IconSize::XSmall).color(Color::Muted));
114
115        match &self {
116            ContextPill::Added {
117                context,
118                dupe_name,
119                on_remove,
120                focused,
121                on_click,
122            } => {
123                let status_is_error = matches!(context.status, ContextStatus::Error { .. });
124                let status_is_warning = matches!(context.status, ContextStatus::Warning { .. });
125
126                base_pill
127                    .pr(if on_remove.is_some() { px(2.) } else { px(4.) })
128                    .map(|pill| {
129                        if status_is_error {
130                            pill.bg(cx.theme().status().error_background)
131                                .border_color(cx.theme().status().error_border)
132                        } else if status_is_warning {
133                            pill.bg(cx.theme().status().warning_background)
134                                .border_color(cx.theme().status().warning_border)
135                        } else if *focused {
136                            pill.bg(color.element_background)
137                                .border_color(color.border_focused)
138                        } else {
139                            pill.bg(color.element_background)
140                                .border_color(color.border.opacity(0.5))
141                        }
142                    })
143                    .child(
144                        h_flex()
145                            .id("context-data")
146                            .gap_1()
147                            .child(
148                                div().max_w_64().child(
149                                    Label::new(context.name.clone())
150                                        .size(LabelSize::Small)
151                                        .truncate(),
152                                ),
153                            )
154                            .when_some(context.parent.as_ref(), |element, parent_name| {
155                                if *dupe_name {
156                                    element.child(
157                                        Label::new(parent_name.clone())
158                                            .size(LabelSize::XSmall)
159                                            .color(Color::Muted),
160                                    )
161                                } else {
162                                    element
163                                }
164                            })
165                            .when_some(context.tooltip.as_ref(), |element, tooltip| {
166                                element.tooltip(Tooltip::text(tooltip.clone()))
167                            })
168                            .map(|element| match &context.status {
169                                ContextStatus::Ready => element
170                                    .when_some(
171                                        context.render_hover.as_ref(),
172                                        |element, render_hover| {
173                                            let render_hover = render_hover.clone();
174                                            element.hoverable_tooltip(move |window, cx| {
175                                                render_hover(window, cx)
176                                            })
177                                        },
178                                    )
179                                    .into_any(),
180                                ContextStatus::Loading { message } => element
181                                    .tooltip(ui::Tooltip::text(message.clone()))
182                                    .with_animation(
183                                        "pulsating-ctx-pill",
184                                        Animation::new(Duration::from_secs(2))
185                                            .repeat()
186                                            .with_easing(pulsating_between(0.4, 0.8)),
187                                        |label, delta| label.opacity(delta),
188                                    )
189                                    .into_any_element(),
190                                ContextStatus::Warning { message }
191                                | ContextStatus::Error { message } => element
192                                    .tooltip(ui::Tooltip::text(message.clone()))
193                                    .into_any_element(),
194                            }),
195                    )
196                    .when_some(on_remove.as_ref(), |element, on_remove| {
197                        element.child(
198                            IconButton::new(
199                                context.handle.element_id("remove".into()),
200                                IconName::Close,
201                            )
202                            .shape(IconButtonShape::Square)
203                            .icon_size(IconSize::XSmall)
204                            .tooltip(Tooltip::text("Remove Context"))
205                            .on_click({
206                                let on_remove = on_remove.clone();
207                                move |event, window, cx| on_remove(event, window, cx)
208                            }),
209                        )
210                    })
211                    .when_some(on_click.as_ref(), |element, on_click| {
212                        let on_click = on_click.clone();
213                        element.cursor_pointer().on_click(move |event, window, cx| {
214                            on_click(event, window, cx);
215                            cx.stop_propagation();
216                        })
217                    })
218                    .into_any_element()
219            }
220            ContextPill::Suggested {
221                name,
222                icon_path: _,
223                kind: _,
224                focused,
225                on_click,
226            } => base_pill
227                .cursor_pointer()
228                .pr_1()
229                .border_dashed()
230                .map(|pill| {
231                    if *focused {
232                        pill.border_color(color.border_focused)
233                            .bg(color.element_background.opacity(0.5))
234                    } else {
235                        pill.border_color(color.border)
236                    }
237                })
238                .hover(|style| style.bg(color.element_hover.opacity(0.5)))
239                .child(
240                    div().max_w_64().child(
241                        Label::new(name.clone())
242                            .size(LabelSize::Small)
243                            .color(Color::Muted)
244                            .truncate(),
245                    ),
246                )
247                .tooltip(|_window, cx| {
248                    Tooltip::with_meta("Suggested Context", None, "Click to add it", cx)
249                })
250                .when_some(on_click.as_ref(), |element, on_click| {
251                    let on_click = on_click.clone();
252                    element.on_click(move |event, window, cx| {
253                        on_click(event, window, cx);
254                        cx.stop_propagation();
255                    })
256                })
257                .into_any(),
258        }
259    }
260}
261
262pub enum ContextStatus {
263    Ready,
264    Loading { message: SharedString },
265    Error { message: SharedString },
266    Warning { message: SharedString },
267}
268
269#[derive(RegisterComponent)]
270pub struct AddedContext {
271    pub handle: AgentContextHandle,
272    pub kind: ContextKind,
273    pub name: SharedString,
274    pub parent: Option<SharedString>,
275    pub tooltip: Option<SharedString>,
276    pub icon_path: Option<SharedString>,
277    pub status: ContextStatus,
278    pub render_hover: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
279}
280
281impl AddedContext {
282    pub fn icon(&self) -> Icon {
283        match &self.status {
284            ContextStatus::Warning { .. } => Icon::new(IconName::Warning).color(Color::Warning),
285            ContextStatus::Error { .. } => Icon::new(IconName::XCircle).color(Color::Error),
286            _ => {
287                if let Some(icon_path) = &self.icon_path {
288                    Icon::from_path(icon_path)
289                } else {
290                    Icon::new(self.kind.icon())
291                }
292            }
293        }
294    }
295    /// Creates an `AddedContext` by retrieving relevant details of `AgentContext`. This returns a
296    /// `None` if `DirectoryContext` or `RulesContext` no longer exist.
297    ///
298    /// TODO: `None` cases are unremovable from `ContextStore` and so are a very minor memory leak.
299    pub fn new_pending(
300        handle: AgentContextHandle,
301        prompt_store: Option<&Entity<PromptStore>>,
302        project: &Project,
303        model: Option<&Arc<dyn language_model::LanguageModel>>,
304        cx: &App,
305    ) -> Option<AddedContext> {
306        match handle {
307            AgentContextHandle::File(handle) => {
308                Self::pending_file(handle, project.path_style(cx), cx)
309            }
310            AgentContextHandle::Directory(handle) => Self::pending_directory(handle, project, cx),
311            AgentContextHandle::Symbol(handle) => {
312                Self::pending_symbol(handle, project.path_style(cx), cx)
313            }
314            AgentContextHandle::Selection(handle) => {
315                Self::pending_selection(handle, project.path_style(cx), cx)
316            }
317            AgentContextHandle::FetchedUrl(handle) => Some(Self::fetched_url(handle)),
318            AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)),
319            AgentContextHandle::TextThread(handle) => Some(Self::pending_text_thread(handle, cx)),
320            AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx),
321            AgentContextHandle::Image(handle) => {
322                Some(Self::image(handle, model, project.path_style(cx), cx))
323            }
324        }
325    }
326
327    fn pending_file(
328        handle: FileContextHandle,
329        path_style: PathStyle,
330        cx: &App,
331    ) -> Option<AddedContext> {
332        let full_path = handle
333            .buffer
334            .read(cx)
335            .file()?
336            .full_path(cx)
337            .to_string_lossy()
338            .to_string();
339        Some(Self::file(handle, &full_path, path_style, cx))
340    }
341
342    fn file(
343        handle: FileContextHandle,
344        full_path: &str,
345        path_style: PathStyle,
346        cx: &App,
347    ) -> AddedContext {
348        let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style);
349        AddedContext {
350            kind: ContextKind::File,
351            name,
352            parent,
353            tooltip: Some(SharedString::new(full_path)),
354            icon_path: FileIcons::get_icon(Path::new(full_path), cx),
355            status: ContextStatus::Ready,
356            render_hover: None,
357            handle: AgentContextHandle::File(handle),
358        }
359    }
360
361    fn pending_directory(
362        handle: DirectoryContextHandle,
363        project: &Project,
364        cx: &App,
365    ) -> Option<AddedContext> {
366        let worktree = project.worktree_for_entry(handle.entry_id, cx)?.read(cx);
367        let entry = worktree.entry_for_id(handle.entry_id)?;
368        let full_path = worktree
369            .full_path(&entry.path)
370            .to_string_lossy()
371            .to_string();
372        Some(Self::directory(handle, &full_path, project.path_style(cx)))
373    }
374
375    fn directory(
376        handle: DirectoryContextHandle,
377        full_path: &str,
378        path_style: PathStyle,
379    ) -> AddedContext {
380        let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style);
381        AddedContext {
382            kind: ContextKind::Directory,
383            name,
384            parent,
385            tooltip: Some(SharedString::new(full_path)),
386            icon_path: None,
387            status: ContextStatus::Ready,
388            render_hover: None,
389            handle: AgentContextHandle::Directory(handle),
390        }
391    }
392
393    fn pending_symbol(
394        handle: SymbolContextHandle,
395        path_style: PathStyle,
396        cx: &App,
397    ) -> Option<AddedContext> {
398        let excerpt = ContextFileExcerpt::new(
399            &handle.full_path(cx)?.to_string_lossy(),
400            handle.enclosing_line_range(cx),
401            path_style,
402            cx,
403        );
404        Some(AddedContext {
405            kind: ContextKind::Symbol,
406            name: handle.symbol.clone(),
407            parent: Some(excerpt.file_name_and_range.clone()),
408            tooltip: None,
409            icon_path: None,
410            status: ContextStatus::Ready,
411            render_hover: {
412                let handle = handle.clone();
413                Some(Rc::new(move |_, cx| {
414                    excerpt.hover_view(handle.text(cx), cx).into()
415                }))
416            },
417            handle: AgentContextHandle::Symbol(handle),
418        })
419    }
420
421    fn pending_selection(
422        handle: SelectionContextHandle,
423        path_style: PathStyle,
424        cx: &App,
425    ) -> Option<AddedContext> {
426        let excerpt = ContextFileExcerpt::new(
427            &handle.full_path(cx)?.to_string_lossy(),
428            handle.line_range(cx),
429            path_style,
430            cx,
431        );
432        Some(AddedContext {
433            kind: ContextKind::Selection,
434            name: excerpt.file_name_and_range.clone(),
435            parent: excerpt.parent_name.clone(),
436            tooltip: None,
437            icon_path: excerpt.icon_path.clone(),
438            status: ContextStatus::Ready,
439            render_hover: {
440                let handle = handle.clone();
441                Some(Rc::new(move |_, cx| {
442                    excerpt.hover_view(handle.text(cx), cx).into()
443                }))
444            },
445            handle: AgentContextHandle::Selection(handle),
446        })
447    }
448
449    fn fetched_url(context: FetchedUrlContext) -> AddedContext {
450        AddedContext {
451            kind: ContextKind::FetchedUrl,
452            name: context.url.clone(),
453            parent: None,
454            tooltip: None,
455            icon_path: None,
456            status: ContextStatus::Ready,
457            render_hover: None,
458            handle: AgentContextHandle::FetchedUrl(context),
459        }
460    }
461
462    fn pending_thread(handle: ThreadContextHandle, cx: &App) -> AddedContext {
463        AddedContext {
464            kind: ContextKind::Thread,
465            name: handle.title(cx),
466            parent: None,
467            tooltip: None,
468            icon_path: None,
469            status: if handle.thread.read(cx).is_generating_summary() {
470                ContextStatus::Loading {
471                    message: "Summarizing…".into(),
472                }
473            } else {
474                ContextStatus::Ready
475            },
476            render_hover: {
477                let thread = handle.thread.clone();
478                Some(Rc::new(move |_, cx| {
479                    let text = thread
480                        .update(cx, |thread, cx| thread.summary(cx))
481                        .now_or_never()
482                        .flatten()
483                        .unwrap_or_else(|| SharedString::from(thread.read(cx).to_markdown()));
484                    ContextPillHover::new_text(text, cx).into()
485                }))
486            },
487            handle: AgentContextHandle::Thread(handle),
488        }
489    }
490
491    fn pending_text_thread(handle: TextThreadContextHandle, cx: &App) -> AddedContext {
492        AddedContext {
493            kind: ContextKind::TextThread,
494            name: handle.title(cx),
495            parent: None,
496            tooltip: None,
497            icon_path: None,
498            status: ContextStatus::Ready,
499            render_hover: {
500                let text_thread = handle.text_thread.clone();
501                Some(Rc::new(move |_, cx| {
502                    let text = text_thread.read(cx).to_xml(cx);
503                    ContextPillHover::new_text(text.into(), cx).into()
504                }))
505            },
506            handle: AgentContextHandle::TextThread(handle),
507        }
508    }
509
510    fn pending_rules(
511        handle: RulesContextHandle,
512        prompt_store: Option<&Entity<PromptStore>>,
513        cx: &App,
514    ) -> Option<AddedContext> {
515        let title = prompt_store
516            .as_ref()?
517            .read(cx)
518            .metadata(handle.prompt_id.into())?
519            .title
520            .unwrap_or_else(|| "Unnamed Rule".into());
521        Some(AddedContext {
522            kind: ContextKind::Rules,
523            name: title,
524            parent: None,
525            tooltip: None,
526            icon_path: None,
527            status: ContextStatus::Ready,
528            render_hover: None,
529            handle: AgentContextHandle::Rules(handle),
530        })
531    }
532
533    fn image(
534        context: ImageContext,
535        model: Option<&Arc<dyn language_model::LanguageModel>>,
536        path_style: PathStyle,
537        cx: &App,
538    ) -> AddedContext {
539        let (name, parent, icon_path) = if let Some(full_path) = context.full_path.as_ref() {
540            let (name, parent) =
541                extract_file_name_and_directory_from_full_path(full_path, path_style);
542            let icon_path = FileIcons::get_icon(Path::new(full_path), cx);
543            (name, parent, icon_path)
544        } else {
545            ("Image".into(), None, None)
546        };
547
548        let status = match context.status(model) {
549            ImageStatus::Loading => ContextStatus::Loading {
550                message: "Loading…".into(),
551            },
552            ImageStatus::Error => ContextStatus::Error {
553                message: "Failed to load Image".into(),
554            },
555            ImageStatus::Warning => ContextStatus::Warning {
556                message: format!(
557                    "{} doesn't support attaching Images as Context",
558                    model.map(|m| m.name().0).unwrap_or_else(|| "Model".into())
559                )
560                .into(),
561            },
562            ImageStatus::Ready => ContextStatus::Ready,
563        };
564
565        AddedContext {
566            kind: ContextKind::Image,
567            name,
568            parent,
569            tooltip: None,
570            icon_path,
571            status,
572            render_hover: Some(Rc::new({
573                let image = context.original_image.clone();
574                move |_, cx| {
575                    let image = image.clone();
576                    ContextPillHover::new(cx, move |_, _| {
577                        gpui::img(image.clone())
578                            .max_w_96()
579                            .max_h_96()
580                            .into_any_element()
581                    })
582                    .into()
583                }
584            })),
585            handle: AgentContextHandle::Image(context),
586        }
587    }
588}
589
590fn extract_file_name_and_directory_from_full_path(
591    path: &str,
592    path_style: PathStyle,
593) -> (SharedString, Option<SharedString>) {
594    let (parent, file_name) = path_style.split(path);
595    let parent = parent.and_then(|parent| {
596        let parent = parent.trim_end_matches(path_style.separator());
597        let (_, parent) = path_style.split(parent);
598        if parent.is_empty() {
599            None
600        } else {
601            Some(SharedString::new(parent))
602        }
603    });
604    (SharedString::new(file_name), parent)
605}
606
607#[derive(Debug, Clone)]
608struct ContextFileExcerpt {
609    pub file_name_and_range: SharedString,
610    pub full_path_and_range: SharedString,
611    pub parent_name: Option<SharedString>,
612    pub icon_path: Option<SharedString>,
613}
614
615impl ContextFileExcerpt {
616    pub fn new(full_path: &str, line_range: Range<Point>, path_style: PathStyle, cx: &App) -> Self {
617        let (parent, file_name) = path_style.split(full_path);
618        let line_range_text = format!(" ({}-{})", line_range.start.row + 1, line_range.end.row + 1);
619        let mut full_path_and_range = full_path.to_owned();
620        full_path_and_range.push_str(&line_range_text);
621        let mut file_name_and_range = file_name.to_owned();
622        file_name_and_range.push_str(&line_range_text);
623
624        let parent_name = parent.and_then(|parent| {
625            let parent = parent.trim_end_matches(path_style.separator());
626            let (_, parent) = path_style.split(parent);
627            if parent.is_empty() {
628                None
629            } else {
630                Some(SharedString::new(parent))
631            }
632        });
633
634        let icon_path = FileIcons::get_icon(Path::new(full_path), cx);
635
636        ContextFileExcerpt {
637            file_name_and_range: file_name_and_range.into(),
638            full_path_and_range: full_path_and_range.into(),
639            parent_name,
640            icon_path,
641        }
642    }
643
644    fn hover_view(&self, text: SharedString, cx: &mut App) -> Entity<ContextPillHover> {
645        let icon_path = self.icon_path.clone();
646        let full_path_and_range = self.full_path_and_range.clone();
647        ContextPillHover::new(cx, move |_, cx| {
648            v_flex()
649                .child(
650                    h_flex()
651                        .gap_0p5()
652                        .w_full()
653                        .max_w_full()
654                        .border_b_1()
655                        .border_color(cx.theme().colors().border.opacity(0.6))
656                        .children(
657                            icon_path
658                                .clone()
659                                .map(Icon::from_path)
660                                .map(|icon| icon.color(Color::Muted).size(IconSize::XSmall)),
661                        )
662                        .child(
663                            // TODO: make this truncate on the left.
664                            Label::new(full_path_and_range.clone())
665                                .size(LabelSize::Small)
666                                .ml_1(),
667                        ),
668                )
669                .child(
670                    div()
671                        .id("context-pill-hover-contents")
672                        .overflow_scroll()
673                        .max_w_128()
674                        .max_h_96()
675                        .child(Label::new(text.clone()).buffer_font(cx)),
676                )
677                .into_any_element()
678        })
679    }
680}
681
682struct ContextPillHover {
683    render_hover: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
684}
685
686impl ContextPillHover {
687    fn new(
688        cx: &mut App,
689        render_hover: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
690    ) -> Entity<Self> {
691        cx.new(|_| Self {
692            render_hover: Box::new(render_hover),
693        })
694    }
695
696    fn new_text(content: SharedString, cx: &mut App) -> Entity<Self> {
697        Self::new(cx, move |_, _| {
698            div()
699                .id("context-pill-hover-contents")
700                .overflow_scroll()
701                .max_w_128()
702                .max_h_96()
703                .child(content.clone())
704                .into_any_element()
705        })
706    }
707}
708
709impl Render for ContextPillHover {
710    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
711        tooltip_container(cx, move |this, cx| {
712            this.occlude()
713                .on_mouse_move(|_, _, cx| cx.stop_propagation())
714                .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
715                .child((self.render_hover)(window, cx))
716        })
717    }
718}
719
720impl Component for AddedContext {
721    fn scope() -> ComponentScope {
722        ComponentScope::Agent
723    }
724
725    fn sort_name() -> &'static str {
726        "AddedContext"
727    }
728
729    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
730        let mut next_context_id = ContextId::zero();
731        let image_ready = (
732            "Ready",
733            AddedContext::image(
734                ImageContext {
735                    context_id: next_context_id.post_inc(),
736                    project_path: None,
737                    full_path: None,
738                    original_image: Arc::new(Image::empty()),
739                    image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
740                },
741                None,
742                PathStyle::local(),
743                cx,
744            ),
745        );
746
747        let image_loading = (
748            "Loading",
749            AddedContext::image(
750                ImageContext {
751                    context_id: next_context_id.post_inc(),
752                    project_path: None,
753                    full_path: None,
754                    original_image: Arc::new(Image::empty()),
755                    image_task: cx
756                        .background_spawn(async move {
757                            smol::Timer::after(Duration::from_secs(60 * 5)).await;
758                            Some(LanguageModelImage::empty())
759                        })
760                        .shared(),
761                },
762                None,
763                PathStyle::local(),
764                cx,
765            ),
766        );
767
768        let image_error = (
769            "Error",
770            AddedContext::image(
771                ImageContext {
772                    context_id: next_context_id.post_inc(),
773                    project_path: None,
774                    full_path: None,
775                    original_image: Arc::new(Image::empty()),
776                    image_task: Task::ready(None).shared(),
777                },
778                None,
779                PathStyle::local(),
780                cx,
781            ),
782        );
783
784        Some(
785            v_flex()
786                .gap_6()
787                .children(
788                    vec![image_ready, image_loading, image_error]
789                        .into_iter()
790                        .map(|(text, context)| {
791                            single_example(
792                                text,
793                                ContextPill::added(context, false, false, None).into_any_element(),
794                            )
795                        }),
796                )
797                .into_any(),
798        )
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805    use gpui::App;
806    use language_model::{LanguageModel, fake_provider::FakeLanguageModel};
807    use std::sync::Arc;
808
809    #[gpui::test]
810    fn test_image_context_warning_for_unsupported_model(cx: &mut App) {
811        let model: Arc<dyn LanguageModel> = Arc::new(FakeLanguageModel::default());
812        assert!(!model.supports_images());
813
814        let image_context = ImageContext {
815            context_id: ContextId::zero(),
816            project_path: None,
817            original_image: Arc::new(Image::empty()),
818            image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
819            full_path: None,
820        };
821
822        let added_context =
823            AddedContext::image(image_context, Some(&model), PathStyle::local(), cx);
824
825        assert!(matches!(
826            added_context.status,
827            ContextStatus::Warning { .. }
828        ));
829
830        assert!(matches!(added_context.kind, ContextKind::Image));
831        assert_eq!(added_context.name.as_ref(), "Image");
832        assert!(added_context.parent.is_none());
833        assert!(added_context.icon_path.is_none());
834    }
835
836    #[gpui::test]
837    fn test_image_context_ready_for_no_model(cx: &mut App) {
838        let image_context = ImageContext {
839            context_id: ContextId::zero(),
840            project_path: None,
841            original_image: Arc::new(Image::empty()),
842            image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
843            full_path: None,
844        };
845
846        let added_context = AddedContext::image(image_context, None, PathStyle::local(), cx);
847
848        assert!(
849            matches!(added_context.status, ContextStatus::Ready),
850            "Expected ready status when no model provided"
851        );
852
853        assert!(matches!(added_context.kind, ContextKind::Image));
854        assert_eq!(added_context.name.as_ref(), "Image");
855        assert!(added_context.parent.is_none());
856        assert!(added_context.icon_path.is_none());
857    }
858}