Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors, Icon};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, Empty, Fill, Flex, Hoverable, MouseStateHandle, OffsetPositioning,
|
||||
ParentAnchor, Radius, SavePosition, Stack, Text, DEFAULT_UI_LINE_HEIGHT_RATIO,
|
||||
},
|
||||
platform::Cursor,
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity as _, TypedActionView,
|
||||
View, ViewContext, ViewHandle,
|
||||
};
|
||||
use warpui::{
|
||||
elements::{ParentElement, ParentOffsetBounds},
|
||||
ui_components::components::UiComponent,
|
||||
};
|
||||
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
icons::todo_list_icon,
|
||||
todos::popup::{AgentTodosPopupEvent, AgentTodosPopupView},
|
||||
},
|
||||
blocklist::{BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent},
|
||||
document::ai_document_model::{
|
||||
AIDocumentId, AIDocumentModel, AIDocumentModelEvent, AIDocumentVersion,
|
||||
},
|
||||
},
|
||||
terminal::input::{MenuPositioning, MenuPositioningProvider},
|
||||
ui_components::blended_colors,
|
||||
AIAgentTodoList, BlocklistAIHistoryModel,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
|
||||
const TODO_BUTTON_SAVE_POSITION_ID: &str = "plan_and_todo_list::todo_button";
|
||||
|
||||
/// A context chip that shows the todo list and plan for the active conversation
|
||||
pub struct PlanAndTodoListView {
|
||||
context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
|
||||
terminal_view_id: EntityId,
|
||||
todo_button_mouse_state: MouseStateHandle,
|
||||
plan_button_mouse_state: MouseStateHandle,
|
||||
agent_todos_popup: ViewHandle<AgentTodosPopupView>,
|
||||
is_todo_popup_open: bool,
|
||||
is_in_agent_view: bool,
|
||||
}
|
||||
|
||||
pub enum PlanAndTodoListEvent {
|
||||
OpenAIDocument {
|
||||
document_id: AIDocumentId,
|
||||
document_version: AIDocumentVersion,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlanAndTodoListAction {
|
||||
ToggleTodoPopup,
|
||||
OpenAIDocument {
|
||||
document_id: AIDocumentId,
|
||||
document_version: AIDocumentVersion,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlanAndTodoListView {
|
||||
pub fn new(
|
||||
context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
|
||||
terminal_view_id: EntityId,
|
||||
is_in_agent_view: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let agent_todos_popup = ctx.add_typed_action_view(|ctx| {
|
||||
AgentTodosPopupView::new(terminal_view_id, context_model.clone(), ctx)
|
||||
});
|
||||
ctx.subscribe_to_view(&agent_todos_popup, |me, _, event, ctx| match event {
|
||||
AgentTodosPopupEvent::Close => {
|
||||
me.is_todo_popup_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&AIDocumentModel::handle(ctx),
|
||||
|me, _, event, ctx| match event {
|
||||
AIDocumentModelEvent::DocumentUserEditStatusUpdated { document_id, .. } => {
|
||||
if me.ai_document_id(ctx).is_some_and(|id| id == *document_id) {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
AIDocumentModelEvent::DocumentSaveStatusUpdated { .. } => {}
|
||||
AIDocumentModelEvent::DocumentUpdated { .. } => {}
|
||||
AIDocumentModelEvent::StreamingDocumentsCleared(..) => {}
|
||||
AIDocumentModelEvent::DocumentVisibilityChanged(_) => {}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
|me, _, event, ctx| {
|
||||
if event
|
||||
.terminal_view_id()
|
||||
.is_some_and(|id| id != me.terminal_view_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Note: UpdatedStreamingExchange is not needed here because plan/todo
|
||||
// chips only depend on conversation-level events and UpdatedTodoList,
|
||||
// not on regular content streaming updates.
|
||||
match event.clone() {
|
||||
BlocklistAIHistoryEvent::StartedNewConversation { .. }
|
||||
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. }
|
||||
| BlocklistAIHistoryEvent::AppendedExchange { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedTodoList { .. } => {
|
||||
ctx.notify();
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Subscribe to context model to detect when pending query state changes (e.g., new conversation)
|
||||
ctx.subscribe_to_model(&context_model, |_, _, event, ctx| {
|
||||
if let BlocklistAIContextEvent::PendingQueryStateUpdated = event {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
context_model,
|
||||
menu_positioning_provider,
|
||||
terminal_view_id,
|
||||
todo_button_mouse_state: Default::default(),
|
||||
plan_button_mouse_state: Default::default(),
|
||||
agent_todos_popup,
|
||||
is_in_agent_view,
|
||||
is_todo_popup_open: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_render(&self, app: &AppContext) -> bool {
|
||||
self.ai_document_id(app).is_some() || self.todo_list(app).is_some()
|
||||
}
|
||||
|
||||
fn render_chip_button(
|
||||
&self,
|
||||
content: Box<dyn Element>,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
tool_tip_text: String,
|
||||
corner_radius: CornerRadius,
|
||||
appearance: &Appearance,
|
||||
) -> Hoverable {
|
||||
Hoverable::new(mouse_state_handle.clone(), move |state| {
|
||||
let background = if state.is_hovered() {
|
||||
internal_colors::fg_overlay_2(appearance.theme())
|
||||
} else {
|
||||
internal_colors::fg_overlay_1(appearance.theme())
|
||||
};
|
||||
|
||||
let container = Container::new(content)
|
||||
.with_background(background)
|
||||
.with_padding_left(6.)
|
||||
.with_padding_right(6.)
|
||||
.with_corner_radius(corner_radius)
|
||||
.with_border(
|
||||
Border::all(1.0)
|
||||
.with_border_fill(internal_colors::neutral_3(appearance.theme())),
|
||||
)
|
||||
.with_padding_top(2.)
|
||||
.with_padding_bottom(2.)
|
||||
.finish();
|
||||
|
||||
if state.is_hovered() {
|
||||
let mut stack = Stack::new().with_child(container);
|
||||
|
||||
let tooltip_element = appearance
|
||||
.ui_builder()
|
||||
.tool_tip(tool_tip_text)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
stack.add_positioned_overlay_child(
|
||||
tooltip_element,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -8.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::BottomLeft,
|
||||
),
|
||||
);
|
||||
stack.finish()
|
||||
} else {
|
||||
container
|
||||
}
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
}
|
||||
|
||||
fn render_plan_button(
|
||||
&self,
|
||||
ai_document_id: AIDocumentId,
|
||||
has_todo_list: bool,
|
||||
icon_size: f32,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let icon_element = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::Compass
|
||||
.to_warpui_icon(if self.is_in_agent_view {
|
||||
theme.sub_text_color(blended_colors::neutral_1(theme).into())
|
||||
} else {
|
||||
internal_colors::fg_overlay_7(appearance.theme())
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_height(icon_size)
|
||||
.with_width(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Set height to match other UDI elements
|
||||
let udi_font_size = appearance.monospace_font_size() - 1.;
|
||||
let content_line_height = app
|
||||
.font_cache()
|
||||
.line_height(udi_font_size, appearance.line_height_ratio());
|
||||
let chip_content = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(icon_element)
|
||||
.finish();
|
||||
let chip_content = ConstrainedBox::new(chip_content).with_height(content_line_height);
|
||||
|
||||
let corner_radius = if has_todo_list {
|
||||
CornerRadius::with_left(Radius::Pixels(4.))
|
||||
} else {
|
||||
CornerRadius::with_all(Radius::Pixels(4.))
|
||||
};
|
||||
|
||||
let conversation_is_streaming = self
|
||||
.context_model
|
||||
.as_ref(app)
|
||||
.selected_conversation(app)
|
||||
.is_some_and(|conversation| conversation.status().is_in_progress());
|
||||
let is_document_dirty = AIDocumentModel::as_ref(app)
|
||||
.get_current_document(&ai_document_id)
|
||||
.map(|doc| doc.user_edit_status.is_dirty())
|
||||
.unwrap_or(false);
|
||||
|
||||
let is_agent_unaware_of_plan_edits = conversation_is_streaming && is_document_dirty;
|
||||
|
||||
let plan_button = self
|
||||
.render_chip_button(
|
||||
chip_content.finish(),
|
||||
self.plan_button_mouse_state.clone(),
|
||||
if is_agent_unaware_of_plan_edits {
|
||||
"Agent is unaware of recent plan edits".to_string()
|
||||
} else {
|
||||
"View plan".to_string()
|
||||
},
|
||||
corner_radius,
|
||||
appearance,
|
||||
)
|
||||
.on_click(move |ctx, app, _| {
|
||||
let Some(document_version) = AIDocumentModel::as_ref(app)
|
||||
.get_current_document(&ai_document_id)
|
||||
.map(|doc| doc.version)
|
||||
else {
|
||||
log::warn!("No current document found for AI document ID: {ai_document_id}");
|
||||
return;
|
||||
};
|
||||
|
||||
ctx.dispatch_typed_action(PlanAndTodoListAction::OpenAIDocument {
|
||||
document_id: ai_document_id,
|
||||
document_version,
|
||||
});
|
||||
})
|
||||
.finish();
|
||||
|
||||
if is_agent_unaware_of_plan_edits {
|
||||
// Show a circle indicator to the top right of the plan button
|
||||
let circle_diameter = 6.;
|
||||
let circle_element = Container::new(
|
||||
ConstrainedBox::new(Empty::new().finish())
|
||||
.with_height(circle_diameter)
|
||||
.with_width(circle_diameter)
|
||||
.finish(),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.with_background(Fill::Solid(
|
||||
internal_colors::fg_overlay_7(appearance.theme()).into(),
|
||||
))
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new().with_child(plan_button);
|
||||
stack.add_positioned_child(
|
||||
circle_element,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(circle_diameter / 2., -(circle_diameter / 2.)),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
stack.finish()
|
||||
} else {
|
||||
plan_button
|
||||
}
|
||||
}
|
||||
|
||||
fn todo_list(&self, app: &AppContext) -> Option<AIAgentTodoList> {
|
||||
let todo_list = self
|
||||
.context_model
|
||||
.as_ref(app)
|
||||
.selected_conversation_todolist(app);
|
||||
|
||||
let should_show_todo_button = todo_list.is_some();
|
||||
|
||||
if !should_show_todo_button {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(todo_list) = todo_list {
|
||||
if !todo_list.is_empty() {
|
||||
return Some(todo_list.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn ai_document_id(&self, app: &AppContext) -> Option<AIDocumentId> {
|
||||
self.context_model
|
||||
.as_ref(app)
|
||||
.selected_conversation_id(app)
|
||||
.and_then(|conversation_id| {
|
||||
AIDocumentModel::as_ref(app).get_document_id_by_conversation_id(conversation_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_todo_button(
|
||||
&self,
|
||||
todo_list: &AIAgentTodoList,
|
||||
has_planning_document: bool,
|
||||
icon_size: f32,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let num_todo_items = todo_list.len();
|
||||
let num_completed_todo_items = todo_list.completed_items().len();
|
||||
|
||||
let primary_color = appearance.theme().surface_1();
|
||||
let todo_icon = Container::new(
|
||||
ConstrainedBox::new(
|
||||
todo_list_icon(appearance)
|
||||
.with_color(appearance.theme().sub_text_color(primary_color))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(icon_size)
|
||||
.with_width(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Use the same font sizing conventions as other UDI chips so text height never exceeds icon height
|
||||
let chip_font_size = appearance.monospace_font_size() - 1.0;
|
||||
let line_height_ratio = appearance.line_height_ratio();
|
||||
|
||||
let completed_text = Text::new_inline(
|
||||
format!("{}", num_completed_todo_items + 1),
|
||||
appearance.ui_font_family(),
|
||||
chip_font_size,
|
||||
)
|
||||
.with_color(blended_colors::text_main(appearance.theme(), primary_color))
|
||||
.with_line_height_ratio(line_height_ratio)
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.finish();
|
||||
|
||||
// Separate the "slash" so we can apply a small margin between the slash and the numbers
|
||||
let slash_text = Text::new_inline("/", appearance.ui_font_family(), chip_font_size)
|
||||
.with_color(appearance.theme().sub_text_color(primary_color).into())
|
||||
.with_line_height_ratio(line_height_ratio)
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.finish();
|
||||
|
||||
let total_text = Text::new_inline(
|
||||
format!("{num_todo_items}"),
|
||||
appearance.ui_font_family(),
|
||||
chip_font_size,
|
||||
)
|
||||
.with_color(appearance.theme().sub_text_color(primary_color).into())
|
||||
.with_line_height_ratio(line_height_ratio)
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.finish();
|
||||
|
||||
let content = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(todo_icon)
|
||||
.with_child(Container::new(completed_text).with_margin_left(4.).finish())
|
||||
.with_child(Container::new(slash_text).with_margin_left(2.).finish())
|
||||
.with_child(Container::new(total_text).with_margin_left(2.).finish())
|
||||
.finish();
|
||||
|
||||
let corner_radius = if has_planning_document {
|
||||
CornerRadius::with_right(Radius::Pixels(4.))
|
||||
} else {
|
||||
CornerRadius::with_all(Radius::Pixels(4.))
|
||||
};
|
||||
|
||||
let todo_button = self
|
||||
.render_chip_button(
|
||||
content,
|
||||
self.todo_button_mouse_state.clone(),
|
||||
"View todo list".to_string(),
|
||||
corner_radius,
|
||||
appearance,
|
||||
)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(PlanAndTodoListAction::ToggleTodoPopup);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let todo_button = SavePosition::new(todo_button, TODO_BUTTON_SAVE_POSITION_ID).finish();
|
||||
|
||||
// Todo popup overlay
|
||||
let mut todo_button = Stack::new().with_child(todo_button);
|
||||
if self.is_todo_popup_open {
|
||||
let positioning = match self.menu_positioning_provider.menu_position(app) {
|
||||
MenuPositioning::BelowInputBox => {
|
||||
OffsetPositioning::offset_from_save_position_element(
|
||||
TODO_BUTTON_SAVE_POSITION_ID,
|
||||
vec2f(0., 4.),
|
||||
warpui::elements::PositionedElementOffsetBounds::WindowByPosition,
|
||||
warpui::elements::PositionedElementAnchor::BottomLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
)
|
||||
}
|
||||
MenuPositioning::AboveInputBox => {
|
||||
OffsetPositioning::offset_from_save_position_element(
|
||||
TODO_BUTTON_SAVE_POSITION_ID,
|
||||
vec2f(0., -4.),
|
||||
warpui::elements::PositionedElementOffsetBounds::WindowByPosition,
|
||||
warpui::elements::PositionedElementAnchor::TopLeft,
|
||||
ChildAnchor::BottomLeft,
|
||||
)
|
||||
}
|
||||
};
|
||||
todo_button.add_positioned_overlay_child(
|
||||
ChildView::new(&self.agent_todos_popup).finish(),
|
||||
positioning,
|
||||
);
|
||||
}
|
||||
|
||||
todo_button.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for PlanAndTodoListView {
|
||||
type Event = PlanAndTodoListEvent;
|
||||
}
|
||||
|
||||
impl View for PlanAndTodoListView {
|
||||
fn ui_name() -> &'static str {
|
||||
"PlanAndTodoListView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn warpui::Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
// Calculate icon size
|
||||
let base_icon_size = app.font_cache().line_height(
|
||||
appearance.monospace_font_size(),
|
||||
DEFAULT_UI_LINE_HEIGHT_RATIO / 1.4,
|
||||
);
|
||||
let text_line_height = app.font_cache().line_height(
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
appearance.line_height_ratio(),
|
||||
);
|
||||
let icon_size = (base_icon_size * 1.1).min(text_line_height);
|
||||
|
||||
let todo_list = self.todo_list(app);
|
||||
let ai_document_id = self.ai_document_id(app);
|
||||
|
||||
let mut row = Flex::row();
|
||||
// Only show plan chip when AgentView is not enabled
|
||||
if !FeatureFlag::AgentView.is_enabled() {
|
||||
if let Some(ai_document_id) = ai_document_id {
|
||||
row.add_child(self.render_plan_button(
|
||||
ai_document_id,
|
||||
todo_list.is_some(),
|
||||
icon_size,
|
||||
appearance,
|
||||
app,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(todo_list) = todo_list {
|
||||
row.add_child(self.render_todo_button(
|
||||
&todo_list,
|
||||
ai_document_id.is_some() && !FeatureFlag::AgentView.is_enabled(),
|
||||
icon_size,
|
||||
appearance,
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for PlanAndTodoListView {
|
||||
type Action = PlanAndTodoListAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
PlanAndTodoListAction::ToggleTodoPopup => {
|
||||
self.is_todo_popup_open = !self.is_todo_popup_open;
|
||||
// If we just opened the popup, request initial scroll to the in-progress item
|
||||
if self.is_todo_popup_open {
|
||||
self.agent_todos_popup
|
||||
.update(ctx, |popup, _ctx| popup.scroll_to_in_progress_item());
|
||||
ctx.focus(&self.agent_todos_popup);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
PlanAndTodoListAction::OpenAIDocument {
|
||||
document_id,
|
||||
document_version,
|
||||
} => {
|
||||
ctx.emit(PlanAndTodoListEvent::OpenAIDocument {
|
||||
document_id: *document_id,
|
||||
document_version: *document_version,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, FormattedTextElement,
|
||||
HighlightedHyperlink, HyperlinkLens, MainAxisAlignment, MainAxisSize, ParentElement,
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::{blocklist::error_color, AIRequestUsageModel},
|
||||
auth::AuthStateProvider,
|
||||
network::NetworkStatus,
|
||||
server::ids::ServerId,
|
||||
settings::PrivacySettings,
|
||||
settings_view::SettingsSection,
|
||||
ui_components::icons::Icon,
|
||||
workspace::WorkspaceAction,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
use ai::api_keys::ApiKeyManager;
|
||||
|
||||
const ANONYMOUS_USER_REQUEST_LIMIT_SOFT_GATE_PERCENTAGE: f32 = 0.5;
|
||||
|
||||
const TELEMETRY_DISABLED_PRIMARY_TEXT: &str = "To use AI features,";
|
||||
const ENABLE_ANALYTICS_ACTION_TEXT: &str = "enable analytics";
|
||||
const UPGRADE_TO_BUILD_ACTION_TEXT: &str = "upgrade";
|
||||
|
||||
const NO_CONNECTION_PRIMARY_TEXT: &str = "No internet connection";
|
||||
const ANONYMOUS_USER_REQUEST_LIMIT_SOFT_GATE_PRIMARY_TEXT: &str = "";
|
||||
const ANONYMOUS_USER_REQUEST_LIMIT_HARD_GATE_PRIMARY_TEXT: &str = "At Limit -";
|
||||
const DELINQUENT_DUE_TO_PAYMENT_ISSUE_PRIMARY_TEXT: &str = "Restricted due to payment issue";
|
||||
const OUT_OF_REQUESTS_PRIMARY_TEXT: &str = "Out of credits";
|
||||
|
||||
const ANONYMOUS_USER_REQUEST_LIMIT_ACTION_TEXT: &str = "Sign up for more AI credits";
|
||||
const DELINQUENT_DUE_TO_PAYMENT_ISSUE_ACTION_TEXT: &str = "Manage billing";
|
||||
const OVERAGES_TOGGLEABLE_BUT_NOT_ENABLED_ACTION_TEXT: &str = "Enable premium overages";
|
||||
const MONTHLY_OVERAGES_SPEND_LIMIT_REACHED_ACTION_TEXT: &str = "Increase monthly spend limit";
|
||||
const UPGRADE_TEXT: &str = "Upgrade";
|
||||
const COMPARE_PLANS_TEXT: &str = "Compare plans";
|
||||
const CONTACT_SUPPORT_TEXT: &str = "Contact support";
|
||||
const NON_ADMIN_CONTACT_ADMIN_TEXT: &str = ", contact a team admin";
|
||||
const NON_ADMIN_ASK_ADMIN_TO_ENABLE_OVERAGES_TEXT: &str = ", ask a team admin to enable overages";
|
||||
const NON_ADMIN_ASK_ADMIN_TO_INCREASE_OVERAGES_TEXT: &str =
|
||||
", ask a team admin to increase overages";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PromptAlertAction {
|
||||
SignUpClickedForAnonymousUser,
|
||||
OpenSettingsClicked,
|
||||
OpenPrivacySettingsClicked,
|
||||
ManageBillingClicked { team_uid: ServerId },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PromptAlertEvent {
|
||||
SignupAnonymousUser,
|
||||
OpenBillingAndUsagePage,
|
||||
OpenPrivacyPage,
|
||||
OpenBillingPortal { team_uid: ServerId },
|
||||
}
|
||||
|
||||
/// The alert state of the chip that appears to the right of certain parts of the prompt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PromptAlertState {
|
||||
/// The user is offline (no connection).
|
||||
NoConnection,
|
||||
/// Telemetry is disabled and the user is on a free tier.
|
||||
/// Free tier users must enable telemetry or upgrade to use AI features.
|
||||
TelemetryDisabledOnFreeTier,
|
||||
/// An anonymous user has reached a certain percentage of requests used.
|
||||
/// This doesn't use a primary text to avoid being too in-your-face.
|
||||
AnonymousUserRequestLimitSoftGate,
|
||||
/// An anonymous user has reached the request limit.
|
||||
AnonymousUserRequestLimitHardGate,
|
||||
/// The user is delinquent due to a payment issue.
|
||||
DelinquentDueToPaymentIssue,
|
||||
/// Overages could be turned on, but aren't enabled.
|
||||
OveragesToggleableButNotEnabled,
|
||||
/// Overages are on, but the spend limit is too low.
|
||||
MonthlyOveragesSpendLimitReached,
|
||||
/// The user has reached the request limit.
|
||||
RequestLimitReached,
|
||||
/// No alert should be displayed.
|
||||
NoAlert,
|
||||
}
|
||||
|
||||
pub struct PromptAlertView {
|
||||
state: PromptAlertState,
|
||||
action_hyperlink: HighlightedHyperlink,
|
||||
}
|
||||
|
||||
impl PromptAlertView {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let request_usage_model = AIRequestUsageModel::handle(ctx);
|
||||
let user_workspaces = UserWorkspaces::handle(ctx);
|
||||
let network_status = NetworkStatus::handle(ctx);
|
||||
let privacy_settings = PrivacySettings::handle(ctx);
|
||||
let api_key_manager = ApiKeyManager::handle(ctx);
|
||||
|
||||
ctx.subscribe_to_model(&request_usage_model, |me, _, _, ctx| {
|
||||
me.state = Self::determine_state(ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&user_workspaces, |me, _, _, ctx| {
|
||||
me.state = Self::determine_state(ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&network_status, |me, _, _, ctx| {
|
||||
me.state = Self::determine_state(ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&privacy_settings, |me, _, _, ctx| {
|
||||
me.state = Self::determine_state(ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&api_key_manager, |me, _, _, ctx| {
|
||||
me.state = Self::determine_state(ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
Self {
|
||||
state: Self::determine_state(ctx),
|
||||
action_hyperlink: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn determine_state(app: &AppContext) -> PromptAlertState {
|
||||
// First, if the user is offline, no AI features will work.
|
||||
if !NetworkStatus::as_ref(app).is_online() {
|
||||
return PromptAlertState::NoConnection;
|
||||
}
|
||||
|
||||
// Check if telemetry is disabled for free tier users.
|
||||
// Free tier users must enable telemetry or upgrade to use AI features.
|
||||
let privacy_settings = PrivacySettings::as_ref(app);
|
||||
if !privacy_settings.is_telemetry_enabled {
|
||||
// Fail safe: if billing status is unknown, assume paid to avoid showing confusing message to paying users
|
||||
let is_on_paid_plan = UserWorkspaces::as_ref(app)
|
||||
.current_workspace()
|
||||
.map(|w| w.billing_metadata.is_user_on_paid_plan())
|
||||
.unwrap_or(true);
|
||||
|
||||
if !is_on_paid_plan {
|
||||
return PromptAlertState::TelemetryDisabledOnFreeTier;
|
||||
}
|
||||
}
|
||||
|
||||
let request_usage_model = AIRequestUsageModel::as_ref(app);
|
||||
let has_requests_remaining = request_usage_model.has_requests_remaining();
|
||||
let auth_state = AuthStateProvider::as_ref(app).get();
|
||||
|
||||
// Next, if the user is anonymous, we check if they have reached a certain percentage of requests used.
|
||||
if auth_state
|
||||
.is_anonymous_user_feature_gated()
|
||||
.unwrap_or_default()
|
||||
{
|
||||
let percentage_used = request_usage_model.request_percentage_used();
|
||||
|
||||
if percentage_used >= ANONYMOUS_USER_REQUEST_LIMIT_SOFT_GATE_PERCENTAGE {
|
||||
if has_requests_remaining {
|
||||
return PromptAlertState::AnonymousUserRequestLimitSoftGate;
|
||||
} else {
|
||||
return PromptAlertState::AnonymousUserRequestLimitHardGate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Next, make sure the user isn't delinquent in their plan.
|
||||
let workspace = UserWorkspaces::as_ref(app).current_workspace();
|
||||
if workspace.is_some_and(|w| w.billing_metadata.is_delinquent_due_to_payment_issue()) {
|
||||
return PromptAlertState::DelinquentDueToPaymentIssue;
|
||||
}
|
||||
|
||||
// If there is ever any ai remaining, no alert
|
||||
if request_usage_model.has_any_ai_remaining(app) {
|
||||
return PromptAlertState::NoAlert;
|
||||
}
|
||||
|
||||
// Check if overages are available.
|
||||
if let Some(workspace) = workspace {
|
||||
let are_overages_toggleable = workspace.are_overages_toggleable();
|
||||
let are_overages_enabled = workspace.are_overages_enabled();
|
||||
|
||||
if are_overages_toggleable {
|
||||
if are_overages_enabled {
|
||||
return PromptAlertState::MonthlyOveragesSpendLimitReached;
|
||||
} else {
|
||||
return PromptAlertState::OveragesToggleableButNotEnabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If overages aren't available, and since we already checked that the user
|
||||
// has no requests remaining, we can show the generic request limit reached alert.
|
||||
PromptAlertState::RequestLimitReached
|
||||
}
|
||||
|
||||
pub fn is_no_alert(&self) -> bool {
|
||||
matches!(self.state, PromptAlertState::NoAlert)
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &PromptAlertState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
pub fn does_alert_block_ai_requests(app: &AppContext) -> bool {
|
||||
does_alert_block_ai_requests(&Self::determine_state(app))
|
||||
}
|
||||
|
||||
fn primary_text(
|
||||
&self,
|
||||
state: &PromptAlertState,
|
||||
text_fragments: &mut Vec<FormattedTextFragment>,
|
||||
) {
|
||||
// Add leading space to separate text from icon.
|
||||
//
|
||||
// Use this instead of hardcoded margin so it scales with font size and is consistent
|
||||
// with the space between this primary fragment and the option hyperlink fragment.
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" "));
|
||||
match state {
|
||||
PromptAlertState::NoConnection => {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(
|
||||
NO_CONNECTION_PRIMARY_TEXT,
|
||||
));
|
||||
}
|
||||
PromptAlertState::TelemetryDisabledOnFreeTier => {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(
|
||||
TELEMETRY_DISABLED_PRIMARY_TEXT,
|
||||
));
|
||||
}
|
||||
PromptAlertState::AnonymousUserRequestLimitSoftGate => {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(
|
||||
ANONYMOUS_USER_REQUEST_LIMIT_SOFT_GATE_PRIMARY_TEXT,
|
||||
));
|
||||
}
|
||||
PromptAlertState::AnonymousUserRequestLimitHardGate => {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(
|
||||
ANONYMOUS_USER_REQUEST_LIMIT_HARD_GATE_PRIMARY_TEXT,
|
||||
));
|
||||
}
|
||||
PromptAlertState::DelinquentDueToPaymentIssue => {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(
|
||||
DELINQUENT_DUE_TO_PAYMENT_ISSUE_PRIMARY_TEXT,
|
||||
));
|
||||
}
|
||||
PromptAlertState::OveragesToggleableButNotEnabled
|
||||
| PromptAlertState::MonthlyOveragesSpendLimitReached
|
||||
| PromptAlertState::RequestLimitReached => {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(
|
||||
OUT_OF_REQUESTS_PRIMARY_TEXT,
|
||||
));
|
||||
}
|
||||
PromptAlertState::NoAlert => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_hyperlink(
|
||||
&self,
|
||||
state: &PromptAlertState,
|
||||
text_fragments: &mut Vec<FormattedTextFragment>,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let auth_state = AuthStateProvider::as_ref(app).get();
|
||||
let current_team = UserWorkspaces::as_ref(app).current_team();
|
||||
let has_admin_permissions = current_team.is_some_and(|team| {
|
||||
team.has_admin_permissions(&auth_state.user_email().unwrap_or_default())
|
||||
});
|
||||
|
||||
match state {
|
||||
PromptAlertState::NoConnection => {}
|
||||
PromptAlertState::TelemetryDisabledOnFreeTier => {
|
||||
// Show "enable analytics" action link
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" "));
|
||||
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
||||
ENABLE_ANALYTICS_ACTION_TEXT,
|
||||
PromptAlertAction::OpenPrivacySettingsClicked,
|
||||
));
|
||||
|
||||
// Show "or upgrade to Build" link
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" or "));
|
||||
let upgrade_url = if let Some(team) = UserWorkspaces::as_ref(app).current_team() {
|
||||
UserWorkspaces::upgrade_link_for_team(team.uid)
|
||||
} else {
|
||||
let user_id = auth_state.user_id().unwrap_or_default();
|
||||
UserWorkspaces::upgrade_link(user_id)
|
||||
};
|
||||
text_fragments.push(FormattedTextFragment::hyperlink(
|
||||
UPGRADE_TO_BUILD_ACTION_TEXT,
|
||||
upgrade_url,
|
||||
));
|
||||
text_fragments.push(FormattedTextFragment::plain_text("."));
|
||||
}
|
||||
PromptAlertState::AnonymousUserRequestLimitSoftGate
|
||||
| PromptAlertState::AnonymousUserRequestLimitHardGate => {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" "));
|
||||
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
||||
ANONYMOUS_USER_REQUEST_LIMIT_ACTION_TEXT,
|
||||
PromptAlertAction::SignUpClickedForAnonymousUser,
|
||||
));
|
||||
}
|
||||
PromptAlertState::DelinquentDueToPaymentIssue => {
|
||||
// Check if user is team admin with billing history
|
||||
let has_billing_history = current_team
|
||||
.map(|team| team.has_billing_history)
|
||||
.unwrap_or_default();
|
||||
if has_admin_permissions && has_billing_history {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" "));
|
||||
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
||||
DELINQUENT_DUE_TO_PAYMENT_ISSUE_ACTION_TEXT,
|
||||
PromptAlertAction::ManageBillingClicked {
|
||||
team_uid: current_team.map(|team| team.uid).unwrap_or_default(),
|
||||
},
|
||||
));
|
||||
} else {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(
|
||||
NON_ADMIN_CONTACT_ADMIN_TEXT,
|
||||
));
|
||||
}
|
||||
}
|
||||
PromptAlertState::OveragesToggleableButNotEnabled => {
|
||||
if has_admin_permissions {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" "));
|
||||
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
||||
OVERAGES_TOGGLEABLE_BUT_NOT_ENABLED_ACTION_TEXT,
|
||||
PromptAlertAction::OpenSettingsClicked,
|
||||
));
|
||||
} else {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(
|
||||
NON_ADMIN_ASK_ADMIN_TO_ENABLE_OVERAGES_TEXT,
|
||||
));
|
||||
}
|
||||
}
|
||||
PromptAlertState::MonthlyOveragesSpendLimitReached => {
|
||||
if has_admin_permissions {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" "));
|
||||
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
||||
MONTHLY_OVERAGES_SPEND_LIMIT_REACHED_ACTION_TEXT,
|
||||
PromptAlertAction::OpenSettingsClicked,
|
||||
));
|
||||
} else {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(
|
||||
NON_ADMIN_ASK_ADMIN_TO_INCREASE_OVERAGES_TEXT,
|
||||
));
|
||||
}
|
||||
}
|
||||
PromptAlertState::RequestLimitReached => {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" "));
|
||||
if let Some(team) = UserWorkspaces::as_ref(app).current_team() {
|
||||
if team.billing_metadata.can_upgrade_to_higher_tier_plan() {
|
||||
let upgrade_url = UserWorkspaces::upgrade_link_for_team(team.uid);
|
||||
let upgrade_text = if !has_admin_permissions {
|
||||
COMPARE_PLANS_TEXT
|
||||
} else if team.billing_metadata.can_upgrade_to_build_plan() {
|
||||
"Upgrade to Build"
|
||||
} else {
|
||||
UPGRADE_TEXT
|
||||
};
|
||||
|
||||
text_fragments
|
||||
.push(FormattedTextFragment::hyperlink(upgrade_text, upgrade_url));
|
||||
} else {
|
||||
text_fragments.push(FormattedTextFragment::hyperlink(
|
||||
CONTACT_SUPPORT_TEXT,
|
||||
"mailto:support@warp.dev".to_owned(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let user_id = auth_state.user_id().unwrap_or_default();
|
||||
let upgrade_url = UserWorkspaces::upgrade_link(user_id);
|
||||
let label =
|
||||
if let Some(workspace) = UserWorkspaces::as_ref(app).current_workspace() {
|
||||
if workspace.billing_metadata.can_upgrade_to_build_plan() {
|
||||
"Upgrade to Build"
|
||||
} else {
|
||||
UPGRADE_TEXT
|
||||
}
|
||||
} else {
|
||||
UPGRADE_TEXT
|
||||
};
|
||||
text_fragments.push(FormattedTextFragment::hyperlink(label, upgrade_url));
|
||||
}
|
||||
if UserWorkspaces::as_ref(app).is_byo_api_key_enabled() {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" or "));
|
||||
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
||||
"use your own API keys",
|
||||
WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: "api".to_string(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
PromptAlertState::NoAlert => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn does_alert_block_ai_requests(state: &PromptAlertState) -> bool {
|
||||
match state {
|
||||
PromptAlertState::AnonymousUserRequestLimitSoftGate | PromptAlertState::NoAlert => false,
|
||||
PromptAlertState::NoConnection
|
||||
| PromptAlertState::TelemetryDisabledOnFreeTier
|
||||
| PromptAlertState::AnonymousUserRequestLimitHardGate
|
||||
| PromptAlertState::DelinquentDueToPaymentIssue
|
||||
| PromptAlertState::OveragesToggleableButNotEnabled
|
||||
| PromptAlertState::MonthlyOveragesSpendLimitReached
|
||||
| PromptAlertState::RequestLimitReached => true,
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for PromptAlertView {
|
||||
type Event = PromptAlertEvent;
|
||||
}
|
||||
|
||||
impl View for PromptAlertView {
|
||||
fn ui_name() -> &'static str {
|
||||
"PromptAlertView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let state = Self::determine_state(app);
|
||||
let mut text_fragments = vec![];
|
||||
|
||||
self.primary_text(&state, &mut text_fragments);
|
||||
|
||||
let auth_state = AuthStateProvider::as_ref(app).get();
|
||||
let current_team = UserWorkspaces::as_ref(app).current_team();
|
||||
let has_admin_permissions = auth_state
|
||||
.user_email()
|
||||
.zip(current_team)
|
||||
.is_some_and(|(email, team)| team.has_admin_permissions(&email));
|
||||
|
||||
let can_purchase_addon_credits = current_team
|
||||
.and_then(|team| team.billing_metadata.tier.purchase_add_on_credits_policy)
|
||||
.is_some_and(|policy| policy.enabled);
|
||||
|
||||
let suggest_buy_credits = can_purchase_addon_credits
|
||||
&& has_admin_permissions
|
||||
&& matches!(
|
||||
state,
|
||||
PromptAlertState::RequestLimitReached
|
||||
| PromptAlertState::OveragesToggleableButNotEnabled
|
||||
| PromptAlertState::MonthlyOveragesSpendLimitReached
|
||||
);
|
||||
|
||||
if suggest_buy_credits {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" "));
|
||||
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
||||
"Add credits",
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::BillingAndUsage),
|
||||
));
|
||||
} else {
|
||||
self.action_hyperlink(&state, &mut text_fragments, app);
|
||||
}
|
||||
|
||||
let formatted_text_element = FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(text_fragments)]),
|
||||
appearance.ui_font_size(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
error_color(appearance.theme()),
|
||||
self.action_hyperlink.clone(),
|
||||
)
|
||||
.with_line_height_ratio(1.)
|
||||
.with_hyperlink_font_color(appearance.theme().ansi_fg_blue())
|
||||
.with_no_text_wrapping()
|
||||
.register_default_click_handlers_with_action_support(|hyperlink_lens, event, ctx| {
|
||||
match hyperlink_lens {
|
||||
HyperlinkLens::Url(url) => {
|
||||
ctx.open_url(url);
|
||||
}
|
||||
HyperlinkLens::Action(action_ref) => {
|
||||
if let Some(action) = action_ref.as_any().downcast_ref::<PromptAlertAction>() {
|
||||
event.dispatch_typed_action(action.clone());
|
||||
} else if let Some(action) =
|
||||
action_ref.as_any().downcast_ref::<WorkspaceAction>()
|
||||
{
|
||||
event.dispatch_typed_action(action.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.finish();
|
||||
|
||||
let icon_size = appearance.ui_font_size();
|
||||
|
||||
let mut chip_row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::End);
|
||||
if does_alert_block_ai_requests(&self.state) {
|
||||
chip_row.add_child(
|
||||
ConstrainedBox::new(
|
||||
Icon::AlertTriangle
|
||||
.to_warpui_icon(error_color(appearance.theme()).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
chip_row.add_child(formatted_text_element);
|
||||
|
||||
Container::new(chip_row.finish())
|
||||
.with_margin_right(16.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for PromptAlertView {
|
||||
type Action = PromptAlertAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
PromptAlertAction::SignUpClickedForAnonymousUser => {
|
||||
ctx.emit(PromptAlertEvent::SignupAnonymousUser);
|
||||
}
|
||||
PromptAlertAction::OpenSettingsClicked => {
|
||||
ctx.emit(PromptAlertEvent::OpenBillingAndUsagePage);
|
||||
}
|
||||
PromptAlertAction::OpenPrivacySettingsClicked => {
|
||||
ctx.emit(PromptAlertEvent::OpenPrivacyPage);
|
||||
}
|
||||
PromptAlertAction::ManageBillingClicked { team_uid } => {
|
||||
ctx.emit(PromptAlertEvent::OpenBillingPortal {
|
||||
team_uid: *team_uid,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user