gpui-ai

Composites

Artifact panel

A side panel for generated documents and code with preview and source views, versions, actions, and streaming state.

Artifact panel — gpui-ai

Starts when it scrolls into view

Pop out

NORD FROST · DARK · RADIUS 6/10 · BASE 16PX · SHADOW

Running the real component, compiled to WebAssembly. The frame is 504 px tall, which is what this story measures at the demo width in the default type size — a narrower column or a theme that changes that size will wrap it differently.
Story
artifact
Demo height
504 px
Events
ArtifactPanelEvent

Code

Cut from the gallery story this page runs, so it stays true.

crates/gallery/src/gallery.rsOpen in the galleryImplementation
let tokens = cx.theme().semantic_tokens();
let status: SharedString = self
    .last_artifact_event
    .clone()
    .unwrap_or_else(|| "Switch views or versions, run an action, or close the panel".into());
let version = self.artifact_version.min(ARTIFACT_VERSIONS.len() - 1);
let (version_id, source) = ARTIFACT_VERSIONS[version];
let artifact = Artifact::new("comparison", "Supplier comparison", if version + 1 == ARTIFACT_VERSIONS.len() {
    StreamedContent::running(source.to_owned())
} else {
    StreamedContent::done(source)
})
.kind(ArtifactKind::Markdown)
.versions(ARTIFACT_VERSIONS.iter().enumerate().map(|(index, (id, _))| {
    ArtifactVersion::new(*id, format!("v{}", index + 1))
}))
.active_version(version_id);
let conversation = v_flex()
    .size_full()
    .gap(tokens.spacing.sm)
    .p(tokens.spacing.md)
    .child(
        div()
            .text_xs()
            .text_color(cx.theme().muted_foreground)
            .child("Conversation"),
    )
    .child(
        TextView::markdown(
            "artifact-conversation",
            "**User:** Compare the three suppliers for next month.\n\n**Agent:** I drafted a comparison as a document on the right; v3 is still being written.",
        )
        .selectable(true),
    )
    .when(!self.artifact_open, |this| {
        this.child(
            Button::new("artifact-reopen")
                .outline()
                .small()
                .text_label("Reopen artifact")
                .on_click(cx.listener(|this, _, _, cx| {
                    this.artifact_open = true;
                    this.last_artifact_event = Some("Reopened".into());
                    cx.notify();
                })),
        )
    });
let panel = ArtifactPanel::new("artifact-panel", &artifact)
    .view(self.artifact_view)
    .actions([
        ArtifactAction::new("open", "Open in editor"),
        ArtifactAction::new("export", "Export"),
    ])
    .on_event(cx.listener(|this, event: &ArtifactPanelEvent, _, cx| {
        match event {
            ArtifactPanelEvent::Closed { .. } => this.artifact_open = false,
            ArtifactPanelEvent::ViewSelected { view, .. } => {
                this.artifact_view = *view;
            }
            ArtifactPanelEvent::VersionSelected { version_id, .. } => {
                if let Some(index) = ARTIFACT_VERSIONS
                    .iter()
                    .position(|(id, _)| *id == version_id.as_ref())
                {
                    this.artifact_version = index;
                }
            }
            ArtifactPanelEvent::ActionActivated { .. } => {}
        }
        this.last_artifact_event = Some(format!("{event:?}").into());
        cx.notify();
    }));
v_flex()
    .gap(tokens.spacing.md)
    .child(
        div()
            .id("artifact-story-host")
            .h(px(440.))
            .w_full()
            .border_1()
            .border_color(cx.theme().border)
            .rounded(tokens.radius.lg)
            .overflow_hidden()
            .child(
                h_resizable("artifact-split")
                    .with_state(&self.artifact_split)
                    .child(
                        resizable_panel()
                            .size(px(220.))
                            .size_range(px(160.)..px(360.))
                            .child(conversation),
                    )
                    .child(
                        resizable_panel()
                            .visible(self.artifact_open)
                            .child(div().size_full().p(tokens.spacing.sm).child(panel)),
                    ),
            ),
    )
    .child(
        div()
            .id("artifact-story-status")
            .role(Role::Status)
            .aria_label(status.clone())
            .text_xs()
            .text_color(cx.theme().muted_foreground)
            .child(status),
    )

Events and ownership

Who owns the state
ArtifactPanel renders state supplied by the application; it does not own durable work.
What it emits
Interactive intent is reported through the typed ArtifactPanelEvent contract and stable application IDs.
What it means
A side panel for generated documents and code with preview and source views, versions, actions, and streaming state.
Overflow and motion
Growing content remains reachable in a bounded vertical surface; reduced motion preserves a useful state.

Event types

  • ArtifactPanelEvent

What the demo does and does not show

The demo above is the same Rust as the native component, compiled to WebAssembly and drawn on a WebGPU canvas — not a mock-up, a recording, or a re-implementation. What it paints is what the component paints: layout, colour from the live theme, and motion.

The live browser specimen requires WebGPU; the native component remains the authoritative runtime.

A canvas cannot stand in for the platform, so treat everything that goes through the operating system as unproven here: keyboard handling and shortcuts, screen reader semantics, text input and IME, the clipboard, file drops, and the frame rate you would get on your own GPU. Judge those in a native build. If your browser has no WebGPU, the frame says so rather than showing an empty box.