diff --git a/examples/gallery/assets/frames/frame-1.jpg b/examples/gallery/assets/frames/frame-1.jpg new file mode 100644 index 0000000..f381226 Binary files /dev/null and b/examples/gallery/assets/frames/frame-1.jpg differ diff --git a/examples/gallery/assets/frames/frame-2.jpg b/examples/gallery/assets/frames/frame-2.jpg new file mode 100644 index 0000000..13a21d3 Binary files /dev/null and b/examples/gallery/assets/frames/frame-2.jpg differ diff --git a/examples/gallery/assets/frames/frame-3.jpg b/examples/gallery/assets/frames/frame-3.jpg new file mode 100644 index 0000000..0691954 Binary files /dev/null and b/examples/gallery/assets/frames/frame-3.jpg differ diff --git a/examples/gallery/assets/frames/screen.jpg b/examples/gallery/assets/frames/screen.jpg new file mode 100644 index 0000000..c662e29 Binary files /dev/null and b/examples/gallery/assets/frames/screen.jpg differ diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index aff0d5b..761e076 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -1,3 +1,14 @@ +/// A file under the gallery's assets, by name. +macro_rules! asset { + ($name:literal) => { + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/", + $name + ) + }; +} + mod capture; mod pages; mod probe; diff --git a/examples/gallery/pages/agent/mod.rs b/examples/gallery/pages/agent/mod.rs index 96d28b3..0058607 100644 --- a/examples/gallery/pages/agent/mod.rs +++ b/examples/gallery/pages/agent/mod.rs @@ -1,4 +1,5 @@ mod changes; +mod previews; mod progress; mod tools; @@ -11,7 +12,7 @@ pub const PAGE: Page = Page { number: 24, slug: "agent", title: "Agent", - summary: "An assistant at work: the tools it calls, what it asks before it acts, how far it has come and what it would change.", + summary: "An assistant at work: the tools it calls, what it asks before it acts, how far it has come, what it would change, what it sees and what it makes.", render, script: SCRIPT, }; @@ -39,6 +40,9 @@ const SCRIPT: &[Step] = &[ Step::DownAt("agent-changes", 30.0, 51.0), Step::UpAt("agent-changes", 30.0, 51.0), Step::Wait(200), + Step::Click("agent-pointer-next"), + Step::Wait(400), + Step::Shot("pointer"), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { @@ -48,5 +52,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(progress::steps(window, cx)) .child(progress::agents(window, cx)) .child(changes::changes_section(window, cx)) + .child(previews::watching(window, cx)) + .child(previews::artifacts(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/agent/previews.rs b/examples/gallery/pages/agent/previews.rs new file mode 100644 index 0000000..b951256 --- /dev/null +++ b/examples/gallery/pages/agent/previews.rs @@ -0,0 +1,163 @@ +use ely_gpui_component::{ + agent::{ArtifactPanel, BrowserPreview, ComputerUseViewer, LivePreview}, + buttons::{Button, ButtonVariant}, + chat::CodeBlock, + documents::MarkdownRenderer, + primitives::IconName, + theme::{ActiveTheme, TextSize}, + typography::format::plural, +}; +use gpui::{ + App, ClipboardItem, FontWeight, IntoElement, ParentElement, Styled, Window, div, point, px, +}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +const FRAMES: [&str; 3] = [ + asset!("frames/frame-1.jpg"), + asset!("frames/frame-2.jpg"), + asset!("frames/frame-3.jpg"), +]; + +const ADDRESSES: [&str; 3] = [ + "localhost:5173/navigation", + "localhost:5173/git", + "localhost:5173/chat", +]; + +/// Where the agent points on the screen, and what it does there. +const ACTIONS: [(f32, f32, &str); 3] = [ + (0.42, 0.31, "Click Full name"), + (0.42, 0.31, "Type Ada Lovelace"), + (0.34, 0.82, "Click Password"), +]; + +pub fn watching(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let shown = keep("agent-frame", || 2_usize, window, cx); + let step = keep("agent-pointer", || 0_usize, window, cx); + let (now, at) = (*shown.read(cx), *step.read(cx)); + let (x, y, action) = ACTIONS[at]; + section( + "BrowserPreview / ScreenshotStream / ComputerUseViewer", + "What an agent sees as it works, in frames from the host: a browser with its address and the frames so far, a press on one shows it; and a screen with where the agent points, a ring for each new point, and what it does there.", + cx, + ) + .child( + div() + .flex() + .items_start() + .gap_6() + .child( + div().w(px(480.)).child( + BrowserPreview::new("agent-browser", ADDRESSES[now], FRAMES, now) + .loading(now == FRAMES.len() - 1) + .on_show(move |ix, _, cx| set(&shown, ix, cx)), + ), + ) + .child( + div() + .w(px(420.)) + .flex() + .flex_col() + .gap_3() + .child( + ComputerUseViewer::new("agent-screen", asset!("frames/screen.jpg")) + .pointer(point(x, y)) + .action(action), + ) + .child(probe( + "agent-pointer-next", + Button::new("agent-pointer-next", "Next action") + .variant(ButtonVariant::Secondary) + .on_click(move |_, _, cx| { + let next = (*step.read(cx) + 1) % ACTIONS.len(); + set(&step, next, cx) + }), + )), + ), + ) +} + +const NOTE: &str = "# Lift\n\nA **lift** blends a color toward white. Dark themes lift their accents further, because saturated color on charcoal looks louder.\n\n- Accents lift by 18%\n- Surfaces lift by 4%"; + +const LIFT: &str = + "pub fn lift(color: Hsla, by: f32) -> Hsla {\n color.mix(white(), by.clamp(0.0, 1.0))\n}"; + +/// A small page as the host would draw it. +fn page(builds: usize, cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + div() + .p_6() + .flex() + .flex_col() + .gap_3() + .child( + div() + .text_size(theme.text_size(TextSize::Xl)) + .font_weight(FontWeight::SEMIBOLD) + .child("Lift"), + ) + .child( + div() + .text_color(theme.colors.fg_muted) + .child("Blend a color toward white."), + ) + .child( + div() + .flex() + .child(Button::new("agent-page-try", "Try it").variant(ButtonVariant::Primary)), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .child(format!("Built {}", plural(builds as u64, "time", "times"))), + ) +} + +pub fn artifacts(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let version = keep("agent-version", || 2_usize, window, cx); + let builds = keep("agent-builds", || 1_usize, window, cx); + let (now, built) = (*version.read(cx), *builds.read(cx)); + section( + "ArtifactPanel / ArtifactVersionSwitcher / LivePreview", + "What an agent made, in a panel of its own: the version shown among all, copy and download, the artifact as preview or source; the version switcher is chat::BranchNavigator. A live preview holds the host's page with its address and reload.", + cx, + ) + .child( + div() + .flex() + .items_start() + .gap_6() + .child( + div().w(px(460.)).h(px(360.)).child( + ArtifactPanel::new("agent-note", "Notes on lift", IconName::FileText) + .version(now, 3, move |to, _, cx| set(&version, to, cx)) + .preview(div().p_4().child(MarkdownRenderer::new("agent-note-text", NOTE))) + .source(div().p_3().child(CodeBlock::new("agent-note-source", NOTE).language("markdown"))) + .on_copy(|_, cx| cx.write_to_clipboard(ClipboardItem::new_string(NOTE.to_string()))), + ), + ) + .child( + div().w(px(420.)).h(px(360.)).child( + ArtifactPanel::new("agent-code", "lift.rs", IconName::Code) + .source(div().p_3().child(CodeBlock::new("agent-code-source", LIFT).language("rust"))), + ), + ), + ) + .child( + div() + .w(px(460.)) + .h(px(280.)) + .child( + LivePreview::new("agent-live", "localhost:5173/lift", page(built, cx)) + .on_reload(move |_, cx| { + let next = *builds.read(cx) + 1; + set(&builds, next, cx) + }), + ), + ) +} diff --git a/examples/gallery/pages/chat/blocks.rs b/examples/gallery/pages/chat/blocks.rs index fa84b07..d5ca956 100644 --- a/examples/gallery/pages/chat/blocks.rs +++ b/examples/gallery/pages/chat/blocks.rs @@ -11,16 +11,6 @@ use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px}; use crate::ui::{keep, noise, section, set}; -macro_rules! asset { - ($name:literal) => { - concat!( - env!("CARGO_MANIFEST_DIR"), - "/examples/gallery/assets/", - $name - ) - }; -} - const SHORT: &str = "fn lift(c: f32, t: f32) -> f32 {\n c + t * (1.0 - c)\n}"; const LONG: &str = "/// Every accent in the library, lifted for its theme.\npub struct Accents {\n base: Hsla,\n light: f32,\n dark: f32,\n}\n\nimpl Accents {\n pub fn new(base: Hsla) -> Self {\n Self { base, light: 0.5, dark: 0.62 }\n }\n\n /// The accent as the theme shows it.\n pub fn shown(&self, dark: bool) -> Hsla {\n let t = if dark { self.dark } else { self.light };\n lift(self.base, t)\n }\n\n /// A hover, one step louder.\n pub fn hover(&self, dark: bool) -> Hsla {\n lift(self.shown(dark), 0.08)\n }\n\n /// A press, two steps louder.\n pub fn press(&self, dark: bool) -> Hsla {\n lift(self.shown(dark), 0.16)\n }\n}"; diff --git a/examples/gallery/pages/chat/compose.rs b/examples/gallery/pages/chat/compose.rs index 350bfbe..aea9b94 100644 --- a/examples/gallery/pages/chat/compose.rs +++ b/examples/gallery/pages/chat/compose.rs @@ -19,16 +19,6 @@ use crate::{ ui::{keep, noise, section, set}, }; -macro_rules! asset { - ($name:literal) => { - concat!( - env!("CARGO_MANIFEST_DIR"), - "/examples/gallery/assets/", - $name - ) - }; -} - const SYSTEM: &str = "You answer about color in plain words. Cite sources."; /// The composer's text, which the prompt menu fills. diff --git a/examples/gallery/pages/data/media.rs b/examples/gallery/pages/data/media.rs index c7d5260..e49257a 100644 --- a/examples/gallery/pages/data/media.rs +++ b/examples/gallery/pages/data/media.rs @@ -15,16 +15,6 @@ use crate::{ ui::{keep, section, set}, }; -macro_rules! asset { - ($name:literal) => { - concat!( - env!("CARGO_MANIFEST_DIR"), - "/examples/gallery/assets/", - $name - ) - }; -} - const PICTURES: [(&str, &str); 6] = [ (asset!("dunes-sun.jpg"), "Sunset over the dunes"), (asset!("atrium-stair.jpg"), "A stair that turns"), diff --git a/src/agent/artifact.rs b/src/agent/artifact.rs new file mode 100644 index 0000000..c801f6c --- /dev/null +++ b/src/agent/artifact.rs @@ -0,0 +1,180 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, +}; + +use crate::{ + buttons::{ButtonVariant, IconButton, SegmentedControl}, + chat::BranchNavigator, + forms::{Pick, Run}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::Ellipsis, +}; + +/// What an agent made, in a panel of its own: its title, the version shown among all, copy and download, and the artifact, as preview or source when it has both. +#[derive(IntoElement)] +pub struct ArtifactPanel { + id: ElementId, + title: SharedString, + icon: IconName, + preview: Option, + source: Option, + version: Option<(usize, usize, Pick)>, + on_copy: Option, + on_download: Option, +} + +impl ArtifactPanel { + pub fn new(id: impl Into, title: impl Into, icon: IconName) -> Self { + Self { + id: id.into(), + title: title.into(), + icon, + preview: None, + source: None, + version: None, + on_copy: None, + on_download: None, + } + } + + /// The artifact as it shows, such as a page or a document. + pub fn preview(mut self, element: impl IntoElement) -> Self { + self.preview = Some(element.into_any_element()); + self + } + + /// The artifact as written, its code or markup. + pub fn source(mut self, element: impl IntoElement) -> Self { + self.source = Some(element.into_any_element()); + self + } + + /// The version shown, of all; `on_version` gets the one asked for. + pub fn version( + mut self, + current: usize, + total: usize, + on_version: impl Fn(usize, &mut Window, &mut App) + 'static, + ) -> Self { + self.version = Some((current, total, Rc::new(on_version))); + self + } + + pub fn on_copy(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_copy = Some(Rc::new(handler)); + self + } + + pub fn on_download(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_download = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ArtifactPanel { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + assert!( + self.preview.is_some() || self.source.is_some(), + "artifact {:?} shows neither preview nor source", + self.id + ); + let reading = window.use_keyed_state((self.id.clone(), "source"), cx, |_, _| false); + let both = self.preview.is_some() && self.source.is_some(); + let showing_source = self.preview.is_none() || (both && *reading.read(cx)); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let action = |key: &'static str, icon: IconName, tip: &'static str, run: Run| { + IconButton::new((self.id.clone(), key), icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(tip) + .on_click(move |_, window, cx| { + log::info!("artifact: {tip}"); + run(window, cx) + }) + }; + let view = both.then(|| { + SegmentedControl::new( + (self.id.clone(), "view"), + if showing_source { "source" } else { "preview" }, + ) + .size(ControlSize::Sm) + .segment("preview", "Preview", Some(IconName::Eye)) + .segment("source", "Source", Some(IconName::Code)) + .on_change(move |key, _, cx| { + let source = key.as_ref() == "source"; + log::info!("artifact: shows {key}"); + reading.update(cx, |reading, cx| { + *reading = source; + cx.notify(); + }) + }) + }); + let body = if showing_source { + self.source + } else { + self.preview + }; + div() + .size_full() + .flex() + .flex_col() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .child( + div() + .flex_none() + .flex() + .items_center() + .gap_2() + .px_3() + .py_2() + .border_b_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Sm)) + .child( + Icon::new(self.icon) + .size(IconSize::Sm) + .color(colors.fg_muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .font_weight(FontWeight::MEDIUM) + .child(Ellipsis::new(self.title)), + ) + .children(self.version.map(|(current, total, pick)| { + BranchNavigator::new( + (self.id.clone(), "versions"), + current, + total, + move |to, window, cx| pick(to, window, cx), + ) + })) + .children(view) + .children( + self.on_copy + .map(|copy| action("copy", IconName::Copy, "Copy", copy)), + ) + .children( + self.on_download + .map(|save| action("download", IconName::Download, "Download", save)), + ), + ) + .child( + div() + .id((self.id.clone(), "body")) + .flex_1() + .min_h_0() + .overflow_y_scroll() + .children(body), + ) + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 4e54e06..841d9dc 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,11 +1,15 @@ +mod artifact; mod changes; mod permission; +mod previews; mod progress; #[cfg(all(test, feature = "test-support"))] mod tests; mod tools; +pub use artifact::ArtifactPanel; pub use changes::{ChangeState, FileChange, FileChangeCard, MultiFileDiffReview}; pub use permission::{Permission, PermissionPrompt, ToolApprovalDialog}; +pub use previews::{BrowserPreview, ComputerUseViewer, LivePreview}; pub use progress::{AgentPlan, AgentProgress, AgentState, AgentStatus, AgentStep, AgentStepList}; pub use tools::{ToolCallCard, ToolCallGroup}; diff --git a/src/agent/previews.rs b/src/agent/previews.rs new file mode 100644 index 0000000..c0803ba --- /dev/null +++ b/src/agent/previews.rs @@ -0,0 +1,368 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement, + MouseButton, ParentElement, Point, RenderOnce, SharedString, StatefulInteractiveElement, + Styled, Window, div, prelude::*, relative, +}; + +use crate::{ + buttons::{ButtonVariant, IconButton}, + documents::source, + forms::{Pick, Run}, + motion::{self, Spinner}, + primitives::{FocusRing, Icon, IconName, Image}, + theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize}, + typography::Ellipsis, +}; + +/// Width over height of a frame from the host. +const FRAME: f32 = 16.0 / 10.0; + +/// A box that keeps a frame's shape at the width it is given. +fn framed(cx: &App) -> Div { + let mut frame = div() + .relative() + .w_full() + .overflow_hidden() + .bg(cx.theme().colors.sunken); + frame.style().aspect_ratio = Some(FRAME); + frame +} + +/// The bar over a page: a lock, the address, a spinner while it loads, and the owner's actions. +fn address_bar( + id: &ElementId, + url: SharedString, + loading: bool, + actions: Option, + cx: &App, +) -> Div { + let theme = cx.theme(); + let colors = &theme.colors; + div() + .flex() + .items_center() + .gap_2() + .px_3() + .py_1p5() + .border_b_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child( + Icon::new(IconName::Lock) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + .child(div().flex_1().min_w_0().child(Ellipsis::new(url))) + .when(loading, |bar| { + bar.child(Spinner::new((id.clone(), "loading")).size(IconSize::Xs)) + }) + .children(actions) +} + +/// The card a page sits in. +fn card(cx: &App) -> Div { + let theme = cx.theme(); + div() + .w_full() + .flex() + .flex_col() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.surface) +} + +/// A browser an agent drives, as the host shows it: the address, a spinner while a page loads, the frame shown, and the frames so far along the foot; a press on one shows it. +#[derive(IntoElement)] +pub struct BrowserPreview { + id: ElementId, + url: SharedString, + frames: Vec, + shown: usize, + loading: bool, + on_show: Option, +} + +impl BrowserPreview { + /// `frames` are the host's screenshots, oldest first, as paths or addresses; `shown` indexes them. + pub fn new( + id: impl Into, + url: impl Into, + frames: impl IntoIterator>, + shown: usize, + ) -> Self { + let frames: Vec = frames.into_iter().map(Into::into).collect(); + assert!(shown < frames.len(), "frame {shown} of {}", frames.len()); + Self { + id: id.into(), + url: url.into(), + frames, + shown, + loading: false, + on_show: None, + } + } + + pub fn loading(mut self, loading: bool) -> Self { + self.loading = loading; + self + } + + /// Gets the frame pressed along the foot. + pub fn on_show(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_show = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for BrowserPreview { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let thumb = theme.avatar_size(AvatarSize::Lg); + let shown = self.shown; + let strip = (self.frames.len() > 1).then(|| { + div() + .flex() + .gap_1p5() + .p_2() + .border_t_1() + .border_color(colors.border) + .children(self.frames.iter().enumerate().map(|(ix, frame)| { + let show = self.on_show.clone(); + let mut tile = div() + .id((self.id.clone(), format!("frame-{ix}"))) + .relative() + .flex_none() + .h(thumb) + .rounded(theme.radius(Radius::Sm)) + .border_1() + .border_color(if ix == shown { + colors.focus + } else { + colors.border + }) + .child( + Image::new((self.id.clone(), format!("thumb-{ix}")), source(frame)) + .rounded(theme.radius(Radius::Sm)) + .size_full(), + ); + tile.style().aspect_ratio = Some(FRAME); + tile.when_some(show, |tile, show| { + tile.tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) + .on_click(move |_, window, cx| { + log::info!("browser preview: frame {ix}"); + show(ix, window, cx) + }) + }) + })) + }); + card(cx) + .child(address_bar(&self.id, self.url, self.loading, None, cx)) + .child( + framed(cx) + .when(strip.is_none(), |frame| { + frame.rounded_b(theme.radius(Radius::Lg)) + }) + .child({ + let image = + Image::new((self.id.clone(), "frame"), source(&self.frames[shown])) + .size_full(); + if strip.is_none() { + image.rounded_b(theme.radius(Radius::Lg)) + } else { + image + } + }), + ) + .children(strip) + } +} + +/// A screen an agent works on, as the host shows it: the frame, where the agent points, and what it does there; each new point rings once. +#[derive(IntoElement)] +pub struct ComputerUseViewer { + id: ElementId, + frame: SharedString, + pointer: Option>, + action: Option, +} + +impl ComputerUseViewer { + /// `frame` is the host's latest screenshot, a path or an address. + pub fn new(id: impl Into, frame: impl Into) -> Self { + Self { + id: id.into(), + frame: frame.into(), + pointer: None, + action: None, + } + } + + /// Where the agent points, each side a share of the frame from its top left. + pub fn pointer(mut self, at: Point) -> Self { + assert!( + (0.0..=1.0).contains(&at.x) && (0.0..=1.0).contains(&at.y), + "a pointer inside the frame, not {at:?}" + ); + self.pointer = Some(at); + self + } + + /// What the agent does there, such as "click Save". + pub fn action(mut self, text: impl Into) -> Self { + self.action = Some(text.into()); + self + } +} + +impl RenderOnce for ComputerUseViewer { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let moves = motion::changes( + (self.id.clone(), "pointer"), + self.pointer.map(|at| (at.x.to_bits(), at.y.to_bits())), + window, + cx, + ); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let ring = theme.icon_size(IconSize::Lg); + let pointer = self.pointer.map(|at| { + let mark = div() + .flex_none() + .size(ring) + .rounded_full() + .border_2() + .border_color(colors.focus) + .bg(colors.focus.opacity(0.16)); + let mark = if moves == 0 { + mark.into_any_element() + } else { + mark.with_animation( + (self.id.clone(), format!("ring-{moves}")), + Animation::new(motion::duration(motion::SLOW, cx)) + .with_easing(motion::ease_out_cubic), + move |mark, t| mark.bg(colors.focus.opacity(0.16 + 0.24 * (1.0 - t))), + ) + .into_any_element() + }; + div() + .absolute() + .left(relative(at.x)) + .top(relative(at.y)) + .size_0() + .flex() + .items_center() + .justify_center() + .child(mark) + }); + card(cx) + .child( + framed(cx) + .rounded_t(theme.radius(Radius::Lg)) + .when(self.action.is_none(), |frame| { + frame.rounded_b(theme.radius(Radius::Lg)) + }) + .child({ + let image = Image::new((self.id.clone(), "frame"), source(&self.frame)) + .rounded_t(theme.radius(Radius::Lg)) + .size_full(); + if self.action.is_none() { + image.rounded_b(theme.radius(Radius::Lg)) + } else { + image + } + }) + .children(pointer), + ) + .children(self.action.map(|action| { + div() + .flex() + .items_center() + .gap_2() + .px_3() + .py_2() + .border_t_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child( + Icon::new(IconName::MousePointer2) + .size(IconSize::Sm) + .color(colors.fg_subtle), + ) + .child(div().flex_1().min_w_0().child(Ellipsis::new(action))) + })) + } +} + +/// A page as it renders while the agent builds it: its address, a spinner while it updates, reload, and the host's element that draws it. +#[derive(IntoElement)] +pub struct LivePreview { + id: ElementId, + url: SharedString, + updating: bool, + content: AnyElement, + on_reload: Option, +} + +impl LivePreview { + pub fn new( + id: impl Into, + url: impl Into, + content: impl IntoElement, + ) -> Self { + Self { + id: id.into(), + url: url.into(), + updating: false, + content: content.into_any_element(), + on_reload: None, + } + } + + pub fn updating(mut self, updating: bool) -> Self { + self.updating = updating; + self + } + + pub fn on_reload(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_reload = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for LivePreview { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let reload = self.on_reload.map(|reload| { + IconButton::new((self.id.clone(), "reload"), IconName::RotateCw) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Reload") + .on_click(move |_, window, cx| { + log::info!("live preview: reload"); + reload(window, cx) + }) + .into_any_element() + }); + card(cx) + .size_full() + .child(address_bar(&self.id, self.url, self.updating, reload, cx)) + .child( + div() + .id((self.id.clone(), "page")) + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child(self.content), + ) + } +} diff --git a/src/agent/tests/mod.rs b/src/agent/tests/mod.rs index 601e41f..5060db3 100644 --- a/src/agent/tests/mod.rs +++ b/src/agent/tests/mod.rs @@ -4,6 +4,7 @@ use gpui::{ }; mod changes; +mod previews; use super::{AgentProgress, Permission, PermissionPrompt, ToolApprovalDialog}; use crate::{forms, primitives::FocusNext, theme::Theme}; diff --git a/src/agent/tests/previews.rs b/src/agent/tests/previews.rs new file mode 100644 index 0000000..7fa8e71 --- /dev/null +++ b/src/agent/tests/previews.rs @@ -0,0 +1,103 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + Context, IntoElement, KeyBinding, ParentElement, Render, Styled, TestAppContext, Window, + canvas, div, point, px, +}; + +use super::{press, settle}; +use crate::{ + agent::{ArtifactPanel, BrowserPreview, ComputerUseViewer}, + forms, + primitives::{FocusNext, IconName}, + theme::Theme, +}; + +fn setup(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); +} + +/// A browser with three frames, and the frames asked for. +struct Browsing { + asked: Vec, +} + +impl Render for Browsing { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + div().w(px(480.0)).child( + BrowserPreview::new("browser", "localhost:5173", ["a.png", "b.png", "c.png"], 2) + .on_show(move |ix, _, cx| view.update(cx, |browsing, _| browsing.asked.push(ix))), + ) + } +} + +#[gpui::test] +fn a_frame_shows_from_the_keyboard(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Browsing { asked: Vec::new() }); + cx.update(|window, _| window.activate_window()); + settle(cx); + cx.update(|window, _| window.focus_next()); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + assert_eq!( + view.read_with(cx, |browsing, _| browsing.asked.clone()), + [1] + ); +} + +#[test] +#[should_panic(expected = "frame 3 of 3")] +fn a_frame_past_the_last_fails_loud() { + let _ = BrowserPreview::new("browser", "localhost", ["a.png", "b.png", "c.png"], 3); +} + +#[test] +#[should_panic(expected = "a pointer inside the frame")] +fn a_pointer_off_the_frame_fails_loud() { + let _ = ComputerUseViewer::new("screen", "a.png").pointer(point(1.2, 0.5)); +} + +/// A panel with a preview and a source, noting which one drew. +struct Panel { + drew_source: Rc>, +} + +impl Render for Panel { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let drew = self.drew_source.clone(); + div().w(px(480.0)).h(px(320.0)).child( + ArtifactPanel::new("panel", "Notes", IconName::FileText) + .preview(div().h(px(40.0))) + .source( + div().relative().h(px(40.0)).child( + canvas(move |_, _, _| drew.set(true), |_, _, _, _| {}) + .absolute() + .top_0() + .left_0() + .size_full(), + ), + ), + ) + } +} + +#[gpui::test] +fn the_source_shows_when_asked(cx: &mut TestAppContext) { + setup(cx); + let drew = Rc::new(Cell::new(false)); + let seen = drew.clone(); + let (_, cx) = cx.add_window_view(|_, _| Panel { drew_source: seen }); + cx.update(|window, _| window.activate_window()); + settle(cx); + assert!(!drew.get(), "the preview shows first"); + cx.update(|window, _| window.focus_next()); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + assert!(drew.get(), "the source shows once asked"); +} diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index a73d643..6c99df8 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -185,11 +185,11 @@ ## T24d Agent · Previews -- [ ] BrowserPreview / ScreenshotStream — host frames -- [ ] ComputerUseViewer — host frames -- [ ] ArtifactPanel -- [ ] ArtifactVersionSwitcher -- [ ] LivePreview — host content +- [x] BrowserPreview / ScreenshotStream — host frames → `agent::BrowserPreview`, the frames so far along its foot +- [x] ComputerUseViewer — host frames +- [x] ArtifactPanel +- [x] ArtifactVersionSwitcher → `chat::BranchNavigator`, in `agent::ArtifactPanel` +- [x] LivePreview — host content ## T24e Agent · Environment