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