T24d: Agent, previews
- BrowserPreview: the address with a lock and a spinner while it loads, the host's frame shown, and the frames so far along the foot as Tab stops; ScreenshotStream is that strip - ComputerUseViewer: the host's frame, a ring where the agent points that rings again at each new point, and what it does there - LivePreview: the host's page under its address, with reload - ArtifactPanel: title, versions through chat::BranchNavigator, Preview and Source when it has both, copy and download; it fails loud with neither - gallery: frames cut from the gallery's own pages; one asset! macro in main.rs replaces three copies
This commit is contained in:
1 parent
8c92651e6a
commit
dcc4a39e56
16 files changed
+842
-36
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -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 capture;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod probe;
|
mod probe;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod changes;
|
mod changes;
|
||||||
|
mod previews;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ pub const PAGE: Page = Page {
|
|||||||
number: 24,
|
number: 24,
|
||||||
slug: "agent",
|
slug: "agent",
|
||||||
title: "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,
|
render,
|
||||||
script: SCRIPT,
|
script: SCRIPT,
|
||||||
};
|
};
|
||||||
@@ -39,6 +40,9 @@ const SCRIPT: &[Step] = &[
|
|||||||
Step::DownAt("agent-changes", 30.0, 51.0),
|
Step::DownAt("agent-changes", 30.0, 51.0),
|
||||||
Step::UpAt("agent-changes", 30.0, 51.0),
|
Step::UpAt("agent-changes", 30.0, 51.0),
|
||||||
Step::Wait(200),
|
Step::Wait(200),
|
||||||
|
Step::Click("agent-pointer-next"),
|
||||||
|
Step::Wait(400),
|
||||||
|
Step::Shot("pointer"),
|
||||||
];
|
];
|
||||||
|
|
||||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
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::steps(window, cx))
|
||||||
.child(progress::agents(window, cx))
|
.child(progress::agents(window, cx))
|
||||||
.child(changes::changes_section(window, cx))
|
.child(changes::changes_section(window, cx))
|
||||||
|
.child(previews::watching(window, cx))
|
||||||
|
.child(previews::artifacts(window, cx))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,16 +11,6 @@ use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px};
|
|||||||
|
|
||||||
use crate::ui::{keep, noise, section, set};
|
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 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}";
|
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}";
|
||||||
|
|||||||
@@ -19,16 +19,6 @@ use crate::{
|
|||||||
ui::{keep, noise, section, set},
|
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.";
|
const SYSTEM: &str = "You answer about color in plain words. Cite sources.";
|
||||||
|
|
||||||
/// The composer's text, which the prompt menu fills.
|
/// The composer's text, which the prompt menu fills.
|
||||||
|
|||||||
@@ -15,16 +15,6 @@ use crate::{
|
|||||||
ui::{keep, section, set},
|
ui::{keep, section, set},
|
||||||
};
|
};
|
||||||
|
|
||||||
macro_rules! asset {
|
|
||||||
($name:literal) => {
|
|
||||||
concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/examples/gallery/assets/",
|
|
||||||
$name
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const PICTURES: [(&str, &str); 6] = [
|
const PICTURES: [(&str, &str); 6] = [
|
||||||
(asset!("dunes-sun.jpg"), "Sunset over the dunes"),
|
(asset!("dunes-sun.jpg"), "Sunset over the dunes"),
|
||||||
(asset!("atrium-stair.jpg"), "A stair that turns"),
|
(asset!("atrium-stair.jpg"), "A stair that turns"),
|
||||||
|
|||||||
@@ -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<AnyElement>,
|
||||||
|
source: Option<AnyElement>,
|
||||||
|
version: Option<(usize, usize, Pick)>,
|
||||||
|
on_copy: Option<Run>,
|
||||||
|
on_download: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArtifactPanel {
|
||||||
|
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>, 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
|
mod artifact;
|
||||||
mod changes;
|
mod changes;
|
||||||
mod permission;
|
mod permission;
|
||||||
|
mod previews;
|
||||||
mod progress;
|
mod progress;
|
||||||
#[cfg(all(test, feature = "test-support"))]
|
#[cfg(all(test, feature = "test-support"))]
|
||||||
mod tests;
|
mod tests;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
|
pub use artifact::ArtifactPanel;
|
||||||
pub use changes::{ChangeState, FileChange, FileChangeCard, MultiFileDiffReview};
|
pub use changes::{ChangeState, FileChange, FileChangeCard, MultiFileDiffReview};
|
||||||
pub use permission::{Permission, PermissionPrompt, ToolApprovalDialog};
|
pub use permission::{Permission, PermissionPrompt, ToolApprovalDialog};
|
||||||
|
pub use previews::{BrowserPreview, ComputerUseViewer, LivePreview};
|
||||||
pub use progress::{AgentPlan, AgentProgress, AgentState, AgentStatus, AgentStep, AgentStepList};
|
pub use progress::{AgentPlan, AgentProgress, AgentState, AgentStatus, AgentStep, AgentStepList};
|
||||||
pub use tools::{ToolCallCard, ToolCallGroup};
|
pub use tools::{ToolCallCard, ToolCallGroup};
|
||||||
@@ -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<AnyElement>,
|
||||||
|
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<SharedString>,
|
||||||
|
shown: usize,
|
||||||
|
loading: bool,
|
||||||
|
on_show: Option<Pick>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserPreview {
|
||||||
|
/// `frames` are the host's screenshots, oldest first, as paths or addresses; `shown` indexes them.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
url: impl Into<SharedString>,
|
||||||
|
frames: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||||
|
shown: usize,
|
||||||
|
) -> Self {
|
||||||
|
let frames: Vec<SharedString> = 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<Point<f32>>,
|
||||||
|
action: Option<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComputerUseViewer {
|
||||||
|
/// `frame` is the host's latest screenshot, a path or an address.
|
||||||
|
pub fn new(id: impl Into<ElementId>, frame: impl Into<SharedString>) -> 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<f32>) -> 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<SharedString>) -> 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<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LivePreview {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
url: impl Into<SharedString>,
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod changes;
|
mod changes;
|
||||||
|
mod previews;
|
||||||
|
|
||||||
use super::{AgentProgress, Permission, PermissionPrompt, ToolApprovalDialog};
|
use super::{AgentProgress, Permission, PermissionPrompt, ToolApprovalDialog};
|
||||||
use crate::{forms, primitives::FocusNext, theme::Theme};
|
use crate::{forms, primitives::FocusNext, theme::Theme};
|
||||||
|
|||||||
@@ -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<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Browsing {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Cell<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Panel {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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");
|
||||||
|
}
|
||||||
+5
-5
@@ -185,11 +185,11 @@
|
|||||||
|
|
||||||
## T24d Agent · Previews
|
## T24d Agent · Previews
|
||||||
|
|
||||||
- [ ] BrowserPreview / ScreenshotStream — host frames
|
- [x] BrowserPreview / ScreenshotStream — host frames → `agent::BrowserPreview`, the frames so far along its foot
|
||||||
- [ ] ComputerUseViewer — host frames
|
- [x] ComputerUseViewer — host frames
|
||||||
- [ ] ArtifactPanel
|
- [x] ArtifactPanel
|
||||||
- [ ] ArtifactVersionSwitcher
|
- [x] ArtifactVersionSwitcher → `chat::BranchNavigator`, in `agent::ArtifactPanel`
|
||||||
- [ ] LivePreview — host content
|
- [x] LivePreview — host content
|
||||||
|
|
||||||
## T24e Agent · Environment
|
## T24e Agent · Environment
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user