Skip to main content

gpui_ai/
artifact.rs

1//! Artifact side panel: a generated document, code file, or data view shown
2//! beside the conversation.
3//!
4//! An [`Artifact`] carries the generated source as [`StreamedContent`] (so a
5//! panel can render while the agent is still writing), the kind that picks
6//! its preview, and the versions the agent has produced. [`ArtifactPanel`]
7//! renders the header (kind, title, version switcher, close), a
8//! Preview / Source switch, the scrolling body, and an optional action row,
9//! reporting every intent as an [`ArtifactPanelEvent`] keyed by stable IDs.
10//! Width, docking, and resizing belong to the application — compose the
11//! panel inside the upstream resizable group.
12
13use crate::control::ControlMetricsExt as _;
14use crate::decoration::{DecoratedExt as _, Decoration};
15use crate::scrolling::PolicyScrollbarExt as _;
16use crate::surface::CardFrameExt as _;
17use crate::{
18    ButtonLabelExt as _,
19    code_block::CodeBlock,
20    handlers::SharedHandler,
21    status::{StatusBadge, StatusTone},
22    stream::{ProgressState, StreamedContent},
23    surface::{icon_button, meta},
24    theme::SemanticStyledExt as _,
25};
26use gpui::{
27    AnyElement, App, ClickEvent, ElementId, FontWeight, InteractiveElement as _, IntoElement,
28    ParentElement as _, RenderOnce, Role, ScrollHandle, SharedString,
29    StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
30    prelude::FluentBuilder as _,
31};
32use gpui_component::{
33    ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, StyledExt as _,
34    button::Button, h_flex, tab::TabBar, text::TextView, v_flex,
35};
36use std::rc::Rc;
37
38/// What the artifact is, which decides its preview and glyph.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
40pub enum ArtifactKind {
41    /// Source code; previewed as a highlighted block.
42    #[default]
43    Code,
44    /// Markdown prose; previewed as rendered text.
45    Markdown,
46    /// HTML; no native preview, so the source is shown.
47    Html,
48    /// Tabular or structured data; shown as source.
49    Data,
50    /// Anything else.
51    Other,
52}
53
54impl ArtifactKind {
55    /// The short label for the kind.
56    pub fn label(self) -> &'static str {
57        match self {
58            Self::Code => "Code",
59            Self::Markdown => "Document",
60            Self::Html => "HTML",
61            Self::Data => "Data",
62            Self::Other => "Artifact",
63        }
64    }
65
66    fn icon(self) -> IconName {
67        match self {
68            Self::Code => IconName::SquareTerminal,
69            Self::Markdown => IconName::BookOpen,
70            Self::Html => IconName::Globe,
71            Self::Data => IconName::ChartPie,
72            Self::Other => IconName::File,
73        }
74    }
75
76    /// Whether the preview view renders something other than the source.
77    pub fn has_preview(self) -> bool {
78        matches!(self, Self::Markdown | Self::Code)
79    }
80}
81
82/// Which face of the artifact is shown.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
84pub enum ArtifactView {
85    /// The rendered form.
86    #[default]
87    Preview,
88    /// The raw source.
89    Source,
90}
91
92impl ArtifactView {
93    fn index(self) -> usize {
94        match self {
95            Self::Preview => 0,
96            Self::Source => 1,
97        }
98    }
99
100    fn from_index(index: usize) -> Self {
101        if index == 0 {
102            Self::Preview
103        } else {
104            Self::Source
105        }
106    }
107}
108
109/// One produced version of an artifact.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ArtifactVersion {
112    id: SharedString,
113    label: SharedString,
114}
115
116impl ArtifactVersion {
117    /// Creates a version with a stable identifier and a short label.
118    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
119        Self {
120            id: id.into(),
121            label: label.into(),
122        }
123    }
124
125    /// Returns the stable version identifier.
126    pub fn id(&self) -> &SharedString {
127        &self.id
128    }
129
130    /// Returns the visible label.
131    pub fn label(&self) -> &SharedString {
132        &self.label
133    }
134}
135
136/// An application-defined action offered under the artifact.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct ArtifactAction {
139    id: SharedString,
140    label: SharedString,
141}
142
143impl ArtifactAction {
144    /// Creates an action with a stable identifier and its label.
145    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
146        Self {
147            id: id.into(),
148            label: label.into(),
149        }
150    }
151
152    /// Returns the stable action identifier.
153    pub fn id(&self) -> &SharedString {
154        &self.id
155    }
156
157    /// Returns the visible label.
158    pub fn label(&self) -> &SharedString {
159        &self.label
160    }
161}
162
163/// A generated artifact: title, kind, source, and versions.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct Artifact {
166    id: SharedString,
167    title: SharedString,
168    kind: ArtifactKind,
169    language: Option<SharedString>,
170    source: StreamedContent,
171    versions: Vec<ArtifactVersion>,
172    active_version: Option<SharedString>,
173}
174
175impl Artifact {
176    /// Creates an artifact with its title and source.
177    pub fn new(
178        id: impl Into<SharedString>,
179        title: impl Into<SharedString>,
180        source: StreamedContent,
181    ) -> Self {
182        Self {
183            id: id.into(),
184            title: title.into(),
185            kind: ArtifactKind::Code,
186            language: None,
187            source,
188            versions: Vec::new(),
189            active_version: None,
190        }
191    }
192
193    /// Sets the kind (default [`ArtifactKind::Code`]).
194    pub fn kind(mut self, kind: ArtifactKind) -> Self {
195        self.kind = kind;
196        self
197    }
198
199    /// Sets the language used to highlight the source.
200    pub fn language(mut self, language: impl Into<SharedString>) -> Self {
201        self.language = Some(language.into());
202        self
203    }
204
205    /// Sets the produced versions, oldest first.
206    pub fn versions(mut self, versions: impl IntoIterator<Item = ArtifactVersion>) -> Self {
207        self.versions = versions.into_iter().collect();
208        self
209    }
210
211    /// Selects the shown version by ID.
212    pub fn active_version(mut self, version_id: impl Into<SharedString>) -> Self {
213        self.active_version = Some(version_id.into());
214        self
215    }
216
217    /// Returns the stable artifact identifier.
218    pub fn id(&self) -> &SharedString {
219        &self.id
220    }
221
222    /// Returns the title.
223    pub fn title(&self) -> &SharedString {
224        &self.title
225    }
226
227    /// Returns the kind.
228    pub fn artifact_kind(&self) -> ArtifactKind {
229        self.kind
230    }
231
232    /// Returns the source with its lifecycle.
233    pub fn source(&self) -> &StreamedContent {
234        &self.source
235    }
236
237    /// Returns the versions, oldest first.
238    pub fn version_refs(&self) -> &[ArtifactVersion] {
239        &self.versions
240    }
241
242    /// Returns the index of the active version (the last one by default).
243    pub fn active_version_index(&self) -> Option<usize> {
244        if self.versions.is_empty() {
245            return None;
246        }
247        self.active_version
248            .as_ref()
249            .and_then(|id| self.versions.iter().position(|version| &version.id == id))
250            .or(Some(self.versions.len() - 1))
251    }
252
253    /// The accessible name: title, kind, and lifecycle.
254    pub fn accessibility_label(&self) -> String {
255        let mut label = format!("Artifact: {}, {}", self.title, self.kind.label());
256        match self.source.state() {
257            ProgressState::Pending | ProgressState::Running => label.push_str(", generating"),
258            ProgressState::Failed(reason) => {
259                label.push_str(", failed: ");
260                label.push_str(reason);
261            }
262            ProgressState::Complete => {}
263        }
264        label
265    }
266}
267
268/// An interaction emitted by [`ArtifactPanel`].
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub enum ArtifactPanelEvent {
271    /// The panel's close control was activated.
272    Closed {
273        /// Stable artifact identifier.
274        id: SharedString,
275    },
276    /// Preview or Source was chosen.
277    ViewSelected {
278        /// Stable artifact identifier.
279        id: SharedString,
280        /// The chosen face.
281        view: ArtifactView,
282    },
283    /// Another version was chosen.
284    VersionSelected {
285        /// Stable artifact identifier.
286        id: SharedString,
287        /// Stable version identifier.
288        version_id: SharedString,
289    },
290    /// An application action was activated.
291    ActionActivated {
292        /// Stable artifact identifier.
293        id: SharedString,
294        /// Stable action identifier.
295        action_id: SharedString,
296    },
297}
298
299/// The side panel for one artifact.
300///
301/// # Example
302///
303/// ```no_run
304/// # use gpui_ai::prelude::*;
305/// # fn example(artifact: Artifact) {
306/// ArtifactPanel::new("doc", &artifact)
307///     .view(ArtifactView::Preview)
308///     .actions([ArtifactAction::new("open", "Open in editor")])
309///     .on_event(|event, _, _| { /* ArtifactPanelEvent::Closed { id } … */ });
310/// # }
311/// ```
312#[derive(IntoElement)]
313pub struct ArtifactPanel {
314    id: ElementId,
315    style: StyleRefinement,
316    decoration: Decoration,
317    artifact: Artifact,
318    view: ArtifactView,
319    actions: Vec<ArtifactAction>,
320    on_event: Option<SharedHandler<ArtifactPanelEvent>>,
321}
322
323impl ArtifactPanel {
324    /// Layers painted into this panel's frame: one under the content, one
325    /// over it, both clipped to its own shape and neither affecting layout.
326    ///
327    /// This crate ships no effects of its own — what goes in a decoration is
328    /// the application's expression.
329    pub fn decoration(mut self, decoration: Decoration) -> Self {
330        self.decoration = decoration;
331        self
332    }
333
334    /// Creates a panel for an artifact, showing its preview.
335    pub fn new(id: impl Into<ElementId>, artifact: &Artifact) -> Self {
336        Self {
337            id: id.into(),
338            style: StyleRefinement::default(),
339            decoration: Decoration::default(),
340            artifact: artifact.clone(),
341            view: ArtifactView::Preview,
342            actions: Vec::new(),
343            on_event: None,
344        }
345    }
346
347    /// Shows the preview or the source; the switch reports
348    /// [`ArtifactPanelEvent::ViewSelected`] so the application can flip this.
349    pub fn view(mut self, view: ArtifactView) -> Self {
350        self.view = view;
351        self
352    }
353
354    /// Adds application actions under the body.
355    pub fn actions(mut self, actions: impl IntoIterator<Item = ArtifactAction>) -> Self {
356        self.actions = actions.into_iter().collect();
357        self
358    }
359
360    /// Handles typed interactions. Without a handler the panel is static.
361    pub fn on_event(
362        mut self,
363        handler: impl Fn(&ArtifactPanelEvent, &mut Window, &mut App) + 'static,
364    ) -> Self {
365        self.on_event = Some(Rc::new(handler));
366        self
367    }
368}
369
370impl Styled for ArtifactPanel {
371    fn style(&mut self) -> &mut StyleRefinement {
372        &mut self.style
373    }
374}
375
376impl RenderOnce for ArtifactPanel {
377    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
378        let tokens = cx.theme().semantic_tokens();
379        // Taken once: each layer is placed at most once, and a
380        // component with no decoration adds no elements at all.
381        let mut decoration = std::mem::take(&mut self.decoration);
382        let artifact = self.artifact;
383        let handler = self.on_event;
384        let artifact_id = artifact.id.clone();
385        // Shared by every selector below. A `SharedString` clone is a
386        // refcount bump; `to_string` would copy the heap for each one.
387        let debug_id = artifact_id.clone();
388        let root_id = self.id.clone();
389        let label: SharedString = artifact.accessibility_label().into();
390        let generating =
391            artifact.source.is_streaming() || *artifact.source.state() == ProgressState::Pending;
392        let failed = match artifact.source.state() {
393            ProgressState::Failed(reason) => Some(reason.clone()),
394            _ => None,
395        };
396        let view = if artifact.kind.has_preview() {
397            self.view
398        } else {
399            ArtifactView::Source
400        };
401
402        // Header: kind glyph, title, lifecycle badge, version switcher, close.
403        let version_nav = artifact.active_version_index().map(|index| {
404            let count = artifact.versions.len();
405            let nav_label: SharedString = format!("Version {} of {count}", index + 1).into();
406            let prev = (index > 0).then(|| artifact.versions[index - 1].id.clone());
407            let next = (index + 1 < count).then(|| artifact.versions[index + 1].id.clone());
408            let prev_debug = debug_id.clone();
409            let next_debug = debug_id.clone();
410            let nav_debug = debug_id.clone();
411            let prev_handler = handler.clone();
412            let next_handler = handler.clone();
413            let prev_artifact = artifact_id.clone();
414            let next_artifact = artifact_id.clone();
415            h_flex()
416                .id((root_id.clone(), "versions"))
417                .role(Role::Group)
418                .aria_label(nav_label)
419                .debug_selector(move || format!("artifact-versions-{nav_debug}"))
420                .flex_none()
421                .items_center()
422                .gap(tokens.spacing.xxs)
423                .child(
424                    icon_button(
425                        (root_id.clone(), "version-prev"),
426                        IconName::ChevronLeft,
427                        "Previous version",
428                        window,
429                        cx,
430                    )
431                    .disabled(prev.is_none() || prev_handler.is_none())
432                    .debug_selector(move || format!("artifact-version-prev-{prev_debug}"))
433                    .on_click(move |_: &ClickEvent, window, cx| {
434                        if let (Some(handler), Some(version_id)) = (&prev_handler, &prev) {
435                            handler(
436                                &ArtifactPanelEvent::VersionSelected {
437                                    id: prev_artifact.clone(),
438                                    version_id: version_id.clone(),
439                                },
440                                window,
441                                cx,
442                            )
443                        }
444                    }),
445                )
446                .child(meta(artifact.versions[index].label.clone(), cx))
447                .child(
448                    icon_button(
449                        (root_id.clone(), "version-next"),
450                        IconName::ChevronRight,
451                        "Next version",
452                        window,
453                        cx,
454                    )
455                    .disabled(next.is_none() || next_handler.is_none())
456                    .debug_selector(move || format!("artifact-version-next-{next_debug}"))
457                    .on_click(move |_: &ClickEvent, window, cx| {
458                        if let (Some(handler), Some(version_id)) = (&next_handler, &next) {
459                            handler(
460                                &ArtifactPanelEvent::VersionSelected {
461                                    id: next_artifact.clone(),
462                                    version_id: version_id.clone(),
463                                },
464                                window,
465                                cx,
466                            )
467                        }
468                    }),
469                )
470        });
471        let close = handler.clone().map(|handler| {
472            let close_debug = debug_id.clone();
473            let close_id = artifact_id.clone();
474            icon_button(
475                (root_id.clone(), "close"),
476                IconName::Close,
477                "Close artifact",
478                window,
479                cx,
480            )
481            .debug_selector(move || format!("artifact-close-{close_debug}"))
482            .on_click(move |_: &ClickEvent, window, cx| {
483                handler(
484                    &ArtifactPanelEvent::Closed {
485                        id: close_id.clone(),
486                    },
487                    window,
488                    cx,
489                )
490            })
491        });
492        let generating_debug = debug_id.clone();
493        let header = h_flex()
494            .items_center()
495            .gap(tokens.spacing.sm)
496            .px(tokens.spacing.md)
497            .py(tokens.spacing.sm)
498            .border_b_1()
499            .border_color(cx.theme().border)
500            .child(
501                Icon::new(artifact.kind.icon())
502                    .xsmall()
503                    .text_color(cx.theme().muted_foreground),
504            )
505            .child(
506                div()
507                    .flex()
508                    .flex_col()
509                    .min_w_0()
510                    .flex_1()
511                    .child(
512                        div()
513                            .truncate()
514                            .text_token(tokens.typography.sm)
515                            .font_weight(FontWeight::SEMIBOLD)
516                            .text_color(cx.theme().foreground)
517                            .child(artifact.title.clone()),
518                    )
519                    .child(crate::surface::hint(
520                        match (&artifact.language, artifact.kind) {
521                            (Some(language), ArtifactKind::Code) => {
522                                format!("{} · {language}", artifact.kind.label())
523                            }
524                            _ => artifact.kind.label().to_owned(),
525                        },
526                        cx,
527                    )),
528            )
529            .when(generating, |this| {
530                this.child(
531                    div()
532                        .debug_selector(move || format!("artifact-generating-{generating_debug}"))
533                        .child(
534                            StatusBadge::new((root_id.clone(), "generating"), "Generating")
535                                .tone(StatusTone::Info)
536                                .active(true),
537                        ),
538                )
539            })
540            .when_some(failed.clone(), |this, _| {
541                this.child(
542                    StatusBadge::new((root_id.clone(), "failed"), "Failed")
543                        .tone(StatusTone::Danger),
544                )
545            })
546            .children(version_nav)
547            .children(close);
548
549        // Preview / Source switch; kinds without a preview pin to Source.
550        let tabs = artifact.kind.has_preview().then(|| {
551            let tabs_debug = debug_id.clone();
552            let tab_artifact = artifact_id.clone();
553            let tab_handler = handler.clone();
554            h_flex().px(tokens.spacing.md).pt(tokens.spacing.sm).child(
555                div()
556                    .flex_none()
557                    .debug_selector(move || format!("artifact-tabs-{tabs_debug}"))
558                    .child(
559                        TabBar::new((root_id.clone(), "tabs"))
560                            .segmented()
561                            .children(["Preview", "Source"])
562                            .selected_index(view.index())
563                            .on_click(move |index: &usize, window, cx| {
564                                if let Some(handler) = &tab_handler {
565                                    handler(
566                                        &ArtifactPanelEvent::ViewSelected {
567                                            id: tab_artifact.clone(),
568                                            view: ArtifactView::from_index(*index),
569                                        },
570                                        window,
571                                        cx,
572                                    )
573                                }
574                            }),
575                    ),
576            )
577        });
578
579        let body_id = ElementId::from((root_id.clone(), "body"));
580        let content: AnyElement = match view {
581            ArtifactView::Preview if artifact.kind == ArtifactKind::Markdown => TextView::markdown(
582                (body_id.clone(), "markdown"),
583                artifact.source.text().to_owned(),
584            )
585            .selectable(true)
586            .into_any_element(),
587            _ => {
588                let mut block = CodeBlock::streamed((body_id.clone(), "code"), &artifact.source);
589                if let Some(language) = artifact.language.clone() {
590                    block = block.language(language);
591                } else if artifact.kind == ArtifactKind::Markdown {
592                    block = block.language("markdown");
593                }
594                block.into_any_element()
595            }
596        };
597        let body_debug = debug_id.clone();
598        let scroll_handle = window
599            .use_keyed_state((root_id.clone(), "scroll"), cx, |_, _| ScrollHandle::new())
600            .read(cx)
601            .clone();
602        let body = div()
603            .debug_selector(move || format!("artifact-body-{body_debug}"))
604            .flex_1()
605            .min_h_0()
606            .w_full()
607            .policy_vertical_scrollbar(&scroll_handle, cx)
608            .child(
609                div()
610                    .id(body_id)
611                    .size_full()
612                    .overflow_y_scroll()
613                    .track_scroll(&scroll_handle)
614                    .child(
615                        div()
616                            .p(tokens.spacing.md)
617                            .text_token(tokens.typography.sm)
618                            .child(content),
619                    ),
620            );
621
622        let footer = (!self.actions.is_empty()).then(|| {
623            let mut row = h_flex()
624                .flex_wrap()
625                .items_center()
626                .gap(tokens.spacing.xs)
627                .px(tokens.spacing.md)
628                .py(tokens.spacing.sm)
629                .border_t_1()
630                .border_color(cx.theme().border);
631            for action in &self.actions {
632                let action_debug = action.id.clone();
633                let artifact_debug = artifact_id.clone();
634                let action_id = action.id.clone();
635                let event_artifact = artifact_id.clone();
636                let handler = handler.clone();
637                row = row.child(
638                    div()
639                        .debug_selector(move || {
640                            format!("artifact-action-{artifact_debug}-{action_debug}")
641                        })
642                        .child(
643                            Button::new((root_id.clone(), format!("action-{}", action.id)))
644                                .outline()
645                                .small()
646                                .control_metrics(cx)
647                                .accessibility_id(format!("{artifact_id}-{}", action.id))
648                                .text_label(action.label.clone())
649                                .disabled(handler.is_none())
650                                .on_click(move |_: &ClickEvent, window, cx| {
651                                    if let Some(handler) = &handler {
652                                        handler(
653                                            &ArtifactPanelEvent::ActionActivated {
654                                                id: event_artifact.clone(),
655                                                action_id: action_id.clone(),
656                                            },
657                                            window,
658                                            cx,
659                                        )
660                                    }
661                                }),
662                        ),
663                );
664            }
665            row
666        });
667
668        v_flex()
669            .id(self.id)
670            .role(Role::Group)
671            .aria_label(label)
672            .when_some(failed, |this, reason| this.aria_description(reason))
673            .debug_selector(move || format!("artifact-{debug_id}"))
674            .size_full()
675            .min_w_0()
676            .min_h_0()
677            .card_frame(cx)
678            .decoration_under(&mut decoration)
679            .overflow_hidden()
680            .child(header)
681            .children(tabs)
682            .child(body)
683            .children(footer)
684            .decoration_over(&mut decoration)
685            .refine_style(&self.style)
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn the_active_version_defaults_to_the_latest() {
695        let artifact = Artifact::new("doc", "Doc", StreamedContent::done("x")).versions([
696            ArtifactVersion::new("v1", "v1"),
697            ArtifactVersion::new("v2", "v2"),
698        ]);
699        assert_eq!(artifact.active_version_index(), Some(1));
700        assert_eq!(
701            artifact.clone().active_version("v1").active_version_index(),
702            Some(0)
703        );
704        assert_eq!(
705            artifact.active_version("missing").active_version_index(),
706            Some(1)
707        );
708        assert_eq!(
709            Artifact::new("doc", "Doc", StreamedContent::done("x")).active_version_index(),
710            None
711        );
712    }
713
714    #[test]
715    fn accessible_names_carry_kind_and_lifecycle() {
716        let running = Artifact::new("doc", "Plan", StreamedContent::running("…".to_owned()))
717            .kind(ArtifactKind::Markdown);
718        assert_eq!(
719            running.accessibility_label(),
720            "Artifact: Plan, Document, generating"
721        );
722        let failed = Artifact::new(
723            "doc",
724            "Plan",
725            StreamedContent::failed(String::new(), "Quota exceeded"),
726        );
727        assert_eq!(
728            failed.accessibility_label(),
729            "Artifact: Plan, Code, failed: Quota exceeded"
730        );
731    }
732}