diff --git a/crates/agent/src/assistant.rs b/crates/agent/src/assistant.rs index d454f04343..241d5922e5 100644 --- a/crates/agent/src/assistant.rs +++ b/crates/agent/src/assistant.rs @@ -9,6 +9,7 @@ mod context_picker; mod context_server_configuration; mod context_store; mod context_strip; +mod debug; mod history_store; mod inline_assistant; mod inline_prompt_editor; @@ -47,7 +48,7 @@ pub use crate::thread::{Message, MessageSegment, Thread, ThreadEvent}; pub use crate::thread_store::ThreadStore; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; pub use context_store::ContextStore; -pub use ui::{all_agent_previews, get_agent_preview}; +pub use ui::preview::{all_agent_previews, get_agent_preview}; actions!( agent, diff --git a/crates/agent/src/assistant_panel.rs b/crates/agent/src/assistant_panel.rs index 0e8836ebaa..4017bced75 100644 --- a/crates/agent/src/assistant_panel.rs +++ b/crates/agent/src/assistant_panel.rs @@ -50,7 +50,6 @@ use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio}; use crate::thread_history::{PastContext, PastThread, ThreadHistory}; use crate::thread_store::ThreadStore; -use crate::ui::UsageBanner; use crate::{ AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, @@ -432,6 +431,7 @@ impl AssistantPanel { MessageEditor::new( fs.clone(), workspace.clone(), + user_store.clone(), message_editor_context_store.clone(), prompt_store.clone(), thread_store.downgrade(), @@ -735,6 +735,7 @@ impl AssistantPanel { MessageEditor::new( self.fs.clone(), self.workspace.clone(), + self.user_store.clone(), context_store, self.prompt_store.clone(), self.thread_store.downgrade(), @@ -933,6 +934,7 @@ impl AssistantPanel { MessageEditor::new( self.fs.clone(), self.workspace.clone(), + self.user_store.clone(), context_store, self.prompt_store.clone(), self.thread_store.downgrade(), @@ -1944,22 +1946,6 @@ impl AssistantPanel { }) } - fn render_usage_banner(&self, cx: &mut Context) -> Option { - let plan = self - .user_store - .read(cx) - .current_plan() - .map(|plan| match plan { - Plan::Free => zed_llm_client::Plan::Free, - Plan::ZedPro => zed_llm_client::Plan::ZedPro, - Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial, - }) - .unwrap_or(zed_llm_client::Plan::Free); - let usage = self.thread.read(cx).last_usage()?; - - Some(UsageBanner::new(plan, usage).into_any_element()) - } - fn render_tool_use_limit_reached(&self, cx: &mut Context) -> Option { let tool_use_limit_reached = self .thread @@ -2277,7 +2263,6 @@ impl Render for AssistantPanel { ActiveView::Thread { .. } => parent .child(self.render_active_thread_or_empty_state(window, cx)) .children(self.render_tool_use_limit_reached(cx)) - .children(self.render_usage_banner(cx)) .child(h_flex().child(self.message_editor.clone())) .children(self.render_last_error(cx)), ActiveView::History => parent.child(self.history.clone()), diff --git a/crates/agent/src/debug.rs b/crates/agent/src/debug.rs new file mode 100644 index 0000000000..c6519b5453 --- /dev/null +++ b/crates/agent/src/debug.rs @@ -0,0 +1,124 @@ +#![allow(unused, dead_code)] + +use gpui::Global; +use language_model::RequestUsage; +use std::ops::{Deref, DerefMut}; +use ui::prelude::*; +use zed_llm_client::{Plan, UsageLimit}; + +/// Debug only: Used for testing various account states +/// +/// Use this by initializing it with +/// `cx.set_global(DebugAccountState::default());` somewhere +/// +/// Then call `cx.debug_account()` to get access +#[derive(Clone, Debug)] +pub struct DebugAccountState { + pub enabled: bool, + pub trial_expired: bool, + pub plan: Plan, + pub custom_prompt_usage: RequestUsage, + pub usage_based_billing_enabled: bool, + pub monthly_spending_cap: i32, + pub custom_edit_prediction_usage: UsageLimit, +} + +impl DebugAccountState { + pub fn enabled(&self) -> bool { + self.enabled + } + + pub fn set_enabled(&mut self, enabled: bool) -> &mut Self { + self.enabled = enabled; + self + } + + pub fn set_trial_expired(&mut self, trial_expired: bool) -> &mut Self { + self.trial_expired = trial_expired; + self + } + + pub fn set_plan(&mut self, plan: Plan) -> &mut Self { + self.plan = plan; + self + } + + pub fn set_custom_prompt_usage(&mut self, custom_prompt_usage: RequestUsage) -> &mut Self { + self.custom_prompt_usage = custom_prompt_usage; + self + } + + pub fn set_usage_based_billing_enabled( + &mut self, + usage_based_billing_enabled: bool, + ) -> &mut Self { + self.usage_based_billing_enabled = usage_based_billing_enabled; + self + } + + pub fn set_monthly_spending_cap(&mut self, monthly_spending_cap: i32) -> &mut Self { + self.monthly_spending_cap = monthly_spending_cap; + self + } + + pub fn set_custom_edit_prediction_usage( + &mut self, + custom_edit_prediction_usage: UsageLimit, + ) -> &mut Self { + self.custom_edit_prediction_usage = custom_edit_prediction_usage; + self + } +} + +impl Default for DebugAccountState { + fn default() -> Self { + Self { + enabled: false, + trial_expired: false, + plan: Plan::Free, + custom_prompt_usage: RequestUsage { + limit: UsageLimit::Unlimited, + amount: 0, + }, + usage_based_billing_enabled: false, + // $50.00 + monthly_spending_cap: 5000, + custom_edit_prediction_usage: UsageLimit::Unlimited, + } + } +} + +impl DebugAccountState { + pub fn get_global(cx: &App) -> &Self { + &cx.global::().0 + } +} + +#[derive(Clone, Debug)] +pub struct GlobalDebugAccountState(pub DebugAccountState); + +impl Global for GlobalDebugAccountState {} + +impl Deref for GlobalDebugAccountState { + type Target = DebugAccountState; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for GlobalDebugAccountState { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +pub trait DebugAccount { + fn debug_account(&self) -> &DebugAccountState; +} + +impl DebugAccount for App { + fn debug_account(&self) -> &DebugAccountState { + &self.global::().0 + } +} diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index 2f173e3cce..3e4e1ce9f6 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -4,8 +4,12 @@ use std::sync::Arc; use crate::assistant_model_selector::{AssistantModelSelector, ModelType}; use crate::context::{AgentContextKey, ContextCreasesAddon, ContextLoadResult, load_context}; use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip}; -use crate::ui::{AgentPreview, AnimatedLabel, MaxModeTooltip}; +use crate::ui::{ + AnimatedLabel, MaxModeTooltip, + preview::{AgentPreview, UsageCallout}, +}; use buffer_diff::BufferDiff; +use client::UserStore; use collections::{HashMap, HashSet}; use editor::actions::{MoveUp, Paste}; use editor::{ @@ -27,6 +31,7 @@ use language_model_selector::ToggleModelSelector; use multi_buffer; use project::Project; use prompt_store::PromptStore; +use proto::Plan; use settings::Settings; use std::time::Duration; use theme::ThemeSettings; @@ -53,6 +58,7 @@ pub struct MessageEditor { editor: Entity, workspace: WeakEntity, project: Entity, + user_store: Entity, context_store: Entity, prompt_store: Option>, context_strip: Entity, @@ -126,6 +132,7 @@ impl MessageEditor { pub fn new( fs: Arc, workspace: WeakEntity, + user_store: Entity, context_store: Entity, prompt_store: Option>, thread_store: WeakEntity, @@ -188,6 +195,7 @@ impl MessageEditor { Self { editor: editor.clone(), project: thread.read(cx).project().clone(), + user_store, thread, incompatible_tools_state: incompatible_tools.clone(), workspace, @@ -1030,79 +1038,72 @@ impl MessageEditor { }) } + fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context) -> Option
{ + if !cx.has_flag::() { + return None; + } + + let plan = self + .user_store + .read(cx) + .current_plan() + .map(|plan| match plan { + Plan::Free => zed_llm_client::Plan::Free, + Plan::ZedPro => zed_llm_client::Plan::ZedPro, + Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial, + }) + .unwrap_or(zed_llm_client::Plan::Free); + let usage = self.thread.read(cx).last_usage()?; + + Some( + div() + .child(UsageCallout::new(plan, usage)) + .line_height(line_height), + ) + } + fn render_token_limit_callout( &self, line_height: Pixels, token_usage_ratio: TokenUsageRatio, cx: &mut Context, - ) -> Div { - let heading = if token_usage_ratio == TokenUsageRatio::Exceeded { + ) -> Option
{ + if !cx.has_flag::() { + return None; + } + + let title = if token_usage_ratio == TokenUsageRatio::Exceeded { "Thread reached the token limit" } else { "Thread reaching the token limit soon" }; - h_flex() - .p_2() - .gap_2() - .flex_wrap() - .justify_between() - .bg( - if token_usage_ratio == TokenUsageRatio::Exceeded { - cx.theme().status().error_background.opacity(0.1) - } else { - cx.theme().status().warning_background.opacity(0.1) - }) - .border_t_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .gap_2() - .items_start() - .child( - h_flex() - .h(line_height) - .justify_center() - .child( - if token_usage_ratio == TokenUsageRatio::Exceeded { - Icon::new(IconName::X) - .color(Color::Error) - .size(IconSize::XSmall) - } else { - Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::XSmall) - } - ), - ) - .child( - v_flex() - .mr_auto() - .child(Label::new(heading).size(LabelSize::Small)) - .child( - Label::new( - "Start a new thread from a summary to continue the conversation.", - ) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ), - ) - .child( - Button::new("new-thread", "Start New Thread") - .on_click(cx.listener(|this, _, window, cx| { - let from_thread_id = Some(this.thread.read(cx).id().clone()); + let message = "Start a new thread from a summary to continue the conversation."; - window.dispatch_action(Box::new(NewThread { - from_thread_id - }), cx); - })) - .icon(IconName::Plus) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .label_size(LabelSize::Small), - ) + let icon = if token_usage_ratio == TokenUsageRatio::Exceeded { + Icon::new(IconName::X) + .color(Color::Error) + .size(IconSize::XSmall) + } else { + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::XSmall) + }; + + Some( + div() + .child(ui::Callout::multi_line( + title.into(), + message.into(), + icon, + "Start New Thread".into(), + Box::new(cx.listener(|this, _, window, cx| { + let from_thread_id = Some(this.thread.read(cx).id().clone()); + window.dispatch_action(Box::new(NewThread { from_thread_id }), cx); + })), + )) + .line_height(line_height), + ) } pub fn last_estimated_token_count(&self) -> Option { @@ -1307,8 +1308,16 @@ impl Render for MessageEditor { parent.child(self.render_changed_buffers(&changed_buffers, window, cx)) }) .child(self.render_editor(font_size, line_height, window, cx)) - .when(token_usage_ratio != TokenUsageRatio::Normal, |parent| { - parent.child(self.render_token_limit_callout(line_height, token_usage_ratio, cx)) + .children({ + let usage_callout = self.render_usage_callout(line_height, cx); + + if usage_callout.is_some() { + usage_callout + } else if token_usage_ratio != TokenUsageRatio::Normal { + self.render_token_limit_callout(line_height, token_usage_ratio, cx) + } else { + None + } }) } } @@ -1354,6 +1363,12 @@ impl Component for MessageEditor { fn scope() -> ComponentScope { ComponentScope::Agent } + + fn description() -> Option<&'static str> { + Some( + "The composer experience of the Agent Panel. This interface handles context, composing messages, switching profiles, models and more.", + ) + } } impl AgentPreview for MessageEditor { @@ -1364,16 +1379,18 @@ impl AgentPreview for MessageEditor { window: &mut Window, cx: &mut App, ) -> Option { - if let Some(workspace_entity) = workspace.upgrade() { - let fs = workspace_entity.read(cx).app_state().fs.clone(); - let weak_project = workspace_entity.read(cx).project().clone().downgrade(); + if let Some(workspace) = workspace.upgrade() { + let fs = workspace.read(cx).app_state().fs.clone(); + let user_store = workspace.read(cx).app_state().user_store.clone(); + let weak_project = workspace.read(cx).project().clone().downgrade(); let context_store = cx.new(|_cx| ContextStore::new(weak_project, None)); let thread = active_thread.read(cx).thread().clone(); - let example_message_editor = cx.new(|cx| { + let default_message_editor = cx.new(|cx| { MessageEditor::new( fs, - workspace, + workspace.downgrade(), + user_store, context_store, None, thread_store, @@ -1387,8 +1404,15 @@ impl AgentPreview for MessageEditor { v_flex() .gap_4() .children(vec![single_example( - "Default", - example_message_editor.clone().into_any_element(), + "Default Message Editor", + div() + .w(px(540.)) + .pt_12() + .bg(cx.theme().colors().panel_background) + .border_1() + .border_color(cx.theme().colors().border) + .child(default_message_editor.clone()) + .into_any_element(), )]) .into_any_element(), ) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index e819dd9000..c95824be4f 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -355,6 +355,7 @@ pub struct Thread { request_token_usage: Vec, cumulative_token_usage: TokenUsage, exceeded_window_error: Option, + last_usage: Option, tool_use_limit_reached: bool, feedback: Option, message_feedback: HashMap, @@ -418,6 +419,7 @@ impl Thread { request_token_usage: Vec::new(), cumulative_token_usage: TokenUsage::default(), exceeded_window_error: None, + last_usage: None, tool_use_limit_reached: false, feedback: None, message_feedback: HashMap::default(), @@ -526,6 +528,7 @@ impl Thread { request_token_usage: serialized.request_token_usage, cumulative_token_usage: serialized.cumulative_token_usage, exceeded_window_error: None, + last_usage: None, tool_use_limit_reached: false, feedback: None, message_feedback: HashMap::default(), @@ -817,6 +820,10 @@ impl Thread { .unwrap_or(false) } + pub fn last_usage(&self) -> Option { + self.last_usage + } + pub fn tool_use_limit_reached(&self) -> bool { self.tool_use_limit_reached } @@ -1535,7 +1542,10 @@ impl Thread { CompletionRequestStatus::UsageUpdated { amount, limit } => { - cx.emit(ThreadEvent::UsageUpdated(RequestUsage { limit, amount: amount as i32 })); + let usage = RequestUsage { limit, amount: amount as i32 }; + + thread.last_usage = Some(usage); + cx.emit(ThreadEvent::UsageUpdated(usage)); } CompletionRequestStatus::ToolUseLimitReached => { thread.tool_use_limit_reached = true; diff --git a/crates/agent/src/ui.rs b/crates/agent/src/ui.rs index 1866928499..aaf6c9cf2f 100644 --- a/crates/agent/src/ui.rs +++ b/crates/agent/src/ui.rs @@ -1,14 +1,12 @@ mod agent_notification; -pub mod agent_preview; mod animated_label; mod context_pill; mod max_mode_tooltip; +pub mod preview; mod upsell; mod usage_banner; pub use agent_notification::*; -pub use agent_preview::*; pub use animated_label::*; pub use context_pill::*; pub use max_mode_tooltip::*; -pub use usage_banner::*; diff --git a/crates/agent/src/ui/preview.rs b/crates/agent/src/ui/preview.rs new file mode 100644 index 0000000000..3ab548dcb4 --- /dev/null +++ b/crates/agent/src/ui/preview.rs @@ -0,0 +1,5 @@ +mod agent_preview; +mod usage_callouts; + +pub use agent_preview::*; +pub use usage_callouts::*; diff --git a/crates/agent/src/ui/agent_preview.rs b/crates/agent/src/ui/preview/agent_preview.rs similarity index 92% rename from crates/agent/src/ui/agent_preview.rs rename to crates/agent/src/ui/preview/agent_preview.rs index fafb61114f..360f01fd86 100644 --- a/crates/agent/src/ui/agent_preview.rs +++ b/crates/agent/src/ui/preview/agent_preview.rs @@ -42,14 +42,14 @@ pub trait AgentPreview: Component + Sized { #[macro_export] macro_rules! register_agent_preview { ($type:ty) => { - #[linkme::distributed_slice($crate::ui::agent_preview::__ALL_AGENT_PREVIEWS)] + #[linkme::distributed_slice($crate::ui::preview::__ALL_AGENT_PREVIEWS)] static __REGISTER_AGENT_PREVIEW: fn() -> ( component::ComponentId, - $crate::ui::agent_preview::PreviewFn, + $crate::ui::preview::PreviewFn, ) = || { ( <$type as component::Component>::id(), - <$type as $crate::ui::agent_preview::AgentPreview>::agent_preview, + <$type as $crate::ui::preview::AgentPreview>::agent_preview, ) }; }; diff --git a/crates/agent/src/ui/preview/usage_callouts.rs b/crates/agent/src/ui/preview/usage_callouts.rs new file mode 100644 index 0000000000..17ef7914b6 --- /dev/null +++ b/crates/agent/src/ui/preview/usage_callouts.rs @@ -0,0 +1,204 @@ +use component::{empty_example, example_group_with_title, single_example}; +use gpui::{AnyElement, App, IntoElement, RenderOnce, Window}; +use language_model::RequestUsage; +use ui::{Callout, Color, Icon, IconName, IconSize, prelude::*}; +use zed_llm_client::{Plan, UsageLimit}; + +#[derive(IntoElement, RegisterComponent)] +pub struct UsageCallout { + plan: Plan, + usage: RequestUsage, +} + +impl UsageCallout { + pub fn new(plan: Plan, usage: RequestUsage) -> Self { + Self { plan, usage } + } +} + +impl RenderOnce for UsageCallout { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let (is_limit_reached, is_approaching_limit, remaining) = match self.usage.limit { + UsageLimit::Limited(limit) => { + let percentage = self.usage.amount as f32 / limit as f32; + let is_limit_reached = percentage >= 1.0; + let is_near_limit = percentage >= 0.9 && percentage < 1.0; + ( + is_limit_reached, + is_near_limit, + limit.saturating_sub(self.usage.amount), + ) + } + UsageLimit::Unlimited => (false, false, 0), + }; + + if !is_limit_reached && !is_approaching_limit { + return div().into_any_element(); + } + + let (title, message, button_text, url) = if is_limit_reached { + match self.plan { + Plan::Free => ( + "Out of free prompts", + "Upgrade to continue, wait for the next reset, or switch to API key." + .to_string(), + "Upgrade", + "https://zed.dev/pricing", + ), + Plan::ZedProTrial => ( + "Out of trial prompts", + "Upgrade to Zed Pro to continue, or switch to API key.".to_string(), + "Upgrade", + "https://zed.dev/pricing", + ), + Plan::ZedPro => ( + "Out of included prompts", + "Enable usage based billing to continue.".to_string(), + "Manage", + "https://zed.dev/account", + ), + } + } else { + match self.plan { + Plan::Free => ( + "Reaching Free tier limit soon", + format!( + "{} remaining - Upgrade to increase limit, or switch providers", + remaining + ), + "Upgrade", + "https://zed.dev/pricing", + ), + Plan::ZedProTrial => ( + "Reaching Trial limit soon", + format!( + "{} remaining - Upgrade to increase limit, or switch providers", + remaining + ), + "Upgrade", + "https://zed.dev/pricing", + ), + _ => return div().into_any_element(), + } + }; + + let icon = if is_limit_reached { + Icon::new(IconName::X) + .color(Color::Error) + .size(IconSize::XSmall) + } else { + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::XSmall) + }; + + Callout::multi_line( + title.into(), + message.into(), + icon, + button_text.into(), + Box::new(move |_, _, cx| { + cx.open_url(url); + }), + ) + .into_any_element() + } +} + +impl Component for UsageCallout { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn sort_name() -> &'static str { + "AgentUsageCallout" + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let free_examples = example_group_with_title( + "Free Plan", + vec![ + single_example( + "Approaching limit (90%)", + UsageCallout::new( + Plan::Free, + RequestUsage { + limit: UsageLimit::Limited(50), + amount: 45, // 90% of limit + }, + ) + .into_any_element(), + ), + single_example( + "Limit reached (100%)", + UsageCallout::new( + Plan::Free, + RequestUsage { + limit: UsageLimit::Limited(50), + amount: 50, // 100% of limit + }, + ) + .into_any_element(), + ), + ], + ); + + let trial_examples = example_group_with_title( + "Zed Pro Trial", + vec![ + single_example( + "Approaching limit (90%)", + UsageCallout::new( + Plan::ZedProTrial, + RequestUsage { + limit: UsageLimit::Limited(150), + amount: 135, // 90% of limit + }, + ) + .into_any_element(), + ), + single_example( + "Limit reached (100%)", + UsageCallout::new( + Plan::ZedProTrial, + RequestUsage { + limit: UsageLimit::Limited(150), + amount: 150, // 100% of limit + }, + ) + .into_any_element(), + ), + ], + ); + + let pro_examples = example_group_with_title( + "Zed Pro", + vec![ + single_example( + "Limit reached (100%)", + UsageCallout::new( + Plan::ZedPro, + RequestUsage { + limit: UsageLimit::Limited(500), + amount: 500, // 100% of limit + }, + ) + .into_any_element(), + ), + empty_example("Unlimited plan (no callout shown)"), + ], + ); + + Some( + div() + .p_4() + .flex() + .flex_col() + .gap_4() + .child(free_examples) + .child(trial_examples) + .child(pro_examples) + .into_any_element(), + ) + } +} diff --git a/crates/component/src/component.rs b/crates/component/src/component.rs index 1180b2ff94..83e09d8f57 100644 --- a/crates/component/src/component.rs +++ b/crates/component/src/component.rs @@ -4,8 +4,8 @@ use std::sync::LazyLock; use collections::HashMap; use gpui::{ - AnyElement, App, IntoElement, RenderOnce, SharedString, Window, div, pattern_slash, prelude::*, - px, rems, + AnyElement, App, IntoElement, Pixels, RenderOnce, SharedString, Window, div, pattern_slash, + prelude::*, px, rems, }; use linkme::distributed_slice; use parking_lot::RwLock; @@ -249,13 +249,20 @@ pub struct ComponentExample { pub variant_name: SharedString, pub description: Option, pub element: AnyElement, + pub width: Option, } impl RenderOnce for ComponentExample { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { div() .pt_2() - .w_full() + .map(|this| { + if let Some(width) = self.width { + this.w(width) + } else { + this.w_full() + } + }) .flex() .flex_col() .gap_3() @@ -306,6 +313,7 @@ impl ComponentExample { variant_name: variant_name.into(), element, description: None, + width: None, } } @@ -313,6 +321,11 @@ impl ComponentExample { self.description = Some(description.into()); self } + + pub fn width(mut self, width: Pixels) -> Self { + self.width = Some(width); + self + } } /// A group of component examples. @@ -320,6 +333,7 @@ impl ComponentExample { pub struct ComponentExampleGroup { pub title: Option, pub examples: Vec, + pub width: Option, pub grow: bool, pub vertical: bool, } @@ -330,7 +344,13 @@ impl RenderOnce for ComponentExampleGroup { .flex_col() .text_sm() .text_color(cx.theme().colors().text_muted) - .w_full() + .map(|this| { + if let Some(width) = self.width { + this.w(width) + } else { + this.w_full() + } + }) .when_some(self.title, |this, title| { this.gap_4().child( div() @@ -373,6 +393,7 @@ impl ComponentExampleGroup { Self { title: None, examples, + width: None, grow: false, vertical: false, } @@ -381,10 +402,15 @@ impl ComponentExampleGroup { Self { title: Some(title.into()), examples, + width: None, grow: false, vertical: false, } } + pub fn width(mut self, width: Pixels) -> Self { + self.width = Some(width); + self + } pub fn grow(mut self) -> Self { self.grow = true; self @@ -402,6 +428,10 @@ pub fn single_example( ComponentExample::new(variant_name, example) } +pub fn empty_example(variant_name: impl Into) -> ComponentExample { + ComponentExample::new(variant_name, div().w_full().text_center().items_center().text_xs().opacity(0.4).child("This space is intentionally left blank. It indicates a case that should render nothing.").into_any_element()) +} + pub fn example_group(examples: Vec) -> ComponentExampleGroup { ComponentExampleGroup::new(examples) } diff --git a/crates/component_preview/src/component_preview.rs b/crates/component_preview/src/component_preview.rs index 933053dbf6..2891a5a6cf 100644 --- a/crates/component_preview/src/component_preview.rs +++ b/crates/component_preview/src/component_preview.rs @@ -110,7 +110,6 @@ struct ComponentPreview { active_page: PreviewPage, components: Vec, component_list: ListState, - agent_previews: Vec, cursor_index: usize, language_registry: Arc, workspace: WeakEntity, @@ -179,9 +178,6 @@ impl ComponentPreview { }, ); - // Initialize agent previews - let agent_previews = agent::all_agent_previews(); - let mut component_preview = Self { workspace_id: None, focus_handle: cx.focus_handle(), @@ -195,7 +191,6 @@ impl ComponentPreview { component_map: components().0, components: sorted_components, component_list, - agent_previews, cursor_index: selected_index, filter_editor, filter_text: String::new(), @@ -707,38 +702,22 @@ impl ComponentPreview { } } - fn render_active_thread( - &self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { + fn render_active_thread(&self, cx: &mut Context) -> impl IntoElement { v_flex() .id("render-active-thread") .size_full() .child( - v_flex().children(self.agent_previews.iter().filter_map(|component_id| { - if let (Some(thread_store), Some(active_thread)) = ( - self.thread_store.as_ref().map(|ts| ts.downgrade()), - self.active_thread.clone(), - ) { - agent::get_agent_preview( - component_id, - self.workspace.clone(), - active_thread, - thread_store, - window, - cx, - ) - .map(|element| div().child(element)) - } else { - None - } - })), + div() + .mx_auto() + .w(px(640.)) + .h_full() + .py_8() + .bg(cx.theme().colors().panel_background) + .children(self.active_thread.clone().map(|thread| thread.clone())) + .when_none(&self.active_thread.clone(), |this| { + this.child("No active thread") + }), ) - .children(self.active_thread.clone().map(|thread| thread.clone())) - .when_none(&self.active_thread.clone(), |this| { - this.child("No active thread") - }) .into_any_element() } @@ -852,7 +831,7 @@ impl Render for ComponentPreview { .render_component_page(&id, window, cx) .into_any_element(), PreviewPage::ActiveThread => { - self.render_active_thread(window, cx).into_any_element() + self.render_active_thread(cx).into_any_element() } }), ) diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 7651a6e9a0..6e3c7c78ae 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -1,6 +1,7 @@ mod avatar; mod banner; mod button; +mod callout; mod content_group; mod context_menu; mod disclosure; @@ -41,6 +42,7 @@ mod stories; pub use avatar::*; pub use banner::*; pub use button::*; +pub use callout::*; pub use content_group::*; pub use context_menu::*; pub use disclosure::*; diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs new file mode 100644 index 0000000000..fc2d122486 --- /dev/null +++ b/crates/ui/src/components/callout.rs @@ -0,0 +1,162 @@ +use crate::prelude::*; +use gpui::ClickEvent; + +#[derive(IntoElement, RegisterComponent)] +pub struct Callout { + title: SharedString, + message: Option, + icon: Icon, + cta_label: SharedString, + cta_action: Box, + line_height: Option, +} + +impl Callout { + pub fn single_line( + title: SharedString, + icon: Icon, + cta_label: SharedString, + cta_action: Box, + ) -> Self { + Self { + title, + message: None, + icon, + cta_label, + cta_action, + line_height: None, + } + } + + pub fn multi_line( + title: SharedString, + message: SharedString, + icon: Icon, + cta_label: SharedString, + cta_action: Box, + ) -> Self { + Self { + title, + message: Some(message), + icon, + cta_label, + cta_action, + line_height: None, + } + } + + pub fn line_height(mut self, line_height: Pixels) -> Self { + self.line_height = Some(line_height); + self + } +} + +impl RenderOnce for Callout { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let line_height = self.line_height.unwrap_or(window.line_height()); + + h_flex() + .p_2() + .gap_2() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().colors().panel_background) + .border_t_1() + .border_color(cx.theme().colors().border) + .overflow_x_hidden() + .child( + h_flex() + .flex_shrink() + .overflow_hidden() + .gap_2() + .items_start() + .child( + h_flex() + .h(line_height) + .items_center() + .justify_center() + .child(self.icon), + ) + .child( + v_flex() + .flex_shrink() + .overflow_hidden() + .child( + h_flex() + .h(line_height) + .items_center() + .child(Label::new(self.title).size(LabelSize::Small)), + ) + .when_some(self.message, |this, message| { + this.child( + div() + .w_full() + .flex_1() + .child(message) + .text_ui_sm(cx) + .text_color(cx.theme().colors().text_muted), + ) + }), + ), + ) + .child( + div().flex_none().child( + Button::new("cta", self.cta_label) + .on_click(self.cta_action) + .style(ButtonStyle::Filled) + .label_size(LabelSize::Small), + ), + ) + } +} + +impl Component for Callout { + fn scope() -> ComponentScope { + ComponentScope::Notification + } + + fn description() -> Option<&'static str> { + Some( + "Used to display a callout for situations where the user needs to know some information, and likely make a decision. This might be a thread running out of tokens, or running out of prompts on a plan and needing to upgrade.", + ) + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let callout_examples = vec![ + single_example( + "Single Line", + Callout::single_line( + "Your settings contain deprecated values, please update them.".into(), + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::Small), + "Backup & Update".into(), + Box::new(|_, _, _| {}), + ) + .into_any_element(), + ) + .width(px(580.)), + single_example( + "Multi Line", + Callout::multi_line( + "Thread reached the token limit".into(), + "Start a new thread from a summary to continue the conversation.".into(), + Icon::new(IconName::X) + .color(Color::Error) + .size(IconSize::Small), + "Start New Thread".into(), + Box::new(|_, _, _| {}), + ) + .into_any_element(), + ) + .width(px(580.)), + ]; + + Some( + example_group(callout_examples) + .vertical() + .into_any_element(), + ) + } +}