Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,869 @@
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::terminal::general_settings::GeneralSettings;
use crate::ui_components::blended_colors;
use crate::view_components::{Dropdown, DropdownEvent, DropdownItem, ToastFlavor};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::workspaces::workspace::CustomerType;
use asset_macro::bundled_or_fetched_asset;
use itertools::Itertools;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use settings::Setting as _;
use thousands::Separable;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::Fill;
use warp_graphql::billing::{AddonCreditsOption, StripeSubscriptionPlan};
use warpui::elements::{
Align, Border, CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DropShadow, Flex, FormattedTextElement, HighlightedHyperlink, Image,
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack,
};
use warpui::fonts::{FamilyId, Weight};
use warpui::platform::Cursor;
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
const BUTTON_DIAMETER: f32 = 20.;
const DROPDOWN_WIDTH: f32 = 160.;
const MODAL_HEIGHT: f32 = 540.;
const MODAL_WIDTH: f32 = 876.;
const LEFT_PANEL_WIDTH: f32 = 333.;
const CORNER_RADIUS: f32 = 20.;
const PANEL_PADDING: f32 = 24.;
#[derive(Debug, Clone, PartialEq, Copy)]
pub enum BuildPlanMigrationModalViewAction {
SelectReloadDenomination(usize),
// true => "enabled", false => "disabled"
EnableAutoReloadToggled(bool),
GetStartedClicked,
Close,
OpenUrl(&'static str),
}
#[derive(Default)]
struct StateHandles {
close_button: MouseStateHandle,
upgrade_button: MouseStateHandle,
auto_reload_checkbox: MouseStateHandle,
}
pub struct BuildPlanMigrationModal {
state_handles: StateHandles,
selected_addon_credits_option: usize,
addon_credits_options: Vec<AddonCreditsOption>,
is_updating: bool,
is_dropdown_expanded: bool,
auto_reload_enabled: bool,
reload_denominations_dropdown: ViewHandle<Dropdown<BuildPlanMigrationModalViewAction>>,
}
impl BuildPlanMigrationModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
ctx.subscribe_to_model(
&PricingInfoModel::handle(ctx),
|me, _, event, ctx| match event {
PricingInfoModelEvent::PricingInfoUpdated => {
me.update_addon_credits_options(ctx);
ctx.notify();
}
},
);
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _handle, event, ctx| {
me.handle_workspaces_event(event, ctx);
});
let reload_denominations_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
dropdown.set_top_bar_max_width(DROPDOWN_WIDTH);
dropdown.set_menu_width(DROPDOWN_WIDTH, ctx);
dropdown
});
ctx.subscribe_to_view(
&reload_denominations_dropdown,
|me, _, event, ctx| match event {
DropdownEvent::ToggleExpanded => {
me.is_dropdown_expanded = !me.is_dropdown_expanded;
ctx.notify();
}
DropdownEvent::Close => {
me.is_dropdown_expanded = false;
ctx.notify();
}
},
);
let mut me = BuildPlanMigrationModal {
state_handles: Default::default(),
selected_addon_credits_option: 0,
addon_credits_options: Default::default(),
is_updating: false,
is_dropdown_expanded: false,
auto_reload_enabled: false,
reload_denominations_dropdown,
};
me.update_addon_credits_options(ctx);
me.refresh_addon_credits_settings(ctx);
me
}
fn refresh_addon_credits_settings(&mut self, ctx: &mut ViewContext<Self>) {
let Some(workspace) = UserWorkspaces::as_ref(ctx).current_workspace() else {
return;
};
let addon_credits_settings = &workspace.settings.addon_credits_settings;
self.auto_reload_enabled = addon_credits_settings.auto_reload_enabled;
self.selected_addon_credits_option = addon_credits_settings
.selected_auto_reload_credit_denomination
.and_then(|amount| {
self.addon_credits_options
.iter()
.find_position(|option| option.credits == amount)
})
.map_or(0, |pair| pair.0);
// Update dropdown to reflect the refreshed selection
self.reload_denominations_dropdown
.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(self.selected_addon_credits_option, ctx);
});
}
fn update_addon_credits_options(&mut self, ctx: &mut ViewContext<Self>) {
self.addon_credits_options = PricingInfoModel::as_ref(ctx)
.addon_credits_options()
.map(|opts| opts.to_vec())
.unwrap_or_default();
// Sync the selected denomination after options are updated
self.sync_selected_denomination(ctx);
// Populate dropdown after syncing selection so it shows the correct item
self.populate_reload_denomination_dropdown(ctx);
}
fn sync_selected_denomination(&mut self, ctx: &ViewContext<Self>) {
let Some(workspace) = UserWorkspaces::as_ref(ctx).current_workspace() else {
return;
};
let addon_credits_settings = &workspace.settings.addon_credits_settings;
// Sync the auto-reload enabled flag
self.auto_reload_enabled = addon_credits_settings.auto_reload_enabled;
if let Some(selected_amount) =
addon_credits_settings.selected_auto_reload_credit_denomination
{
// Find the index of the option that matches the selected amount
if let Some((index, _)) = self
.addon_credits_options
.iter()
.enumerate()
.find(|(_, option)| option.credits == selected_amount)
{
self.selected_addon_credits_option = index;
}
}
}
fn handle_workspaces_event(
&mut self,
event: &UserWorkspacesEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess => {
if self.is_updating {
// Close modal on success when we initiated the update
self.is_updating = false;
self.update_addon_credits_options(ctx);
Self::mark_modal_dismissed(ctx);
ctx.emit(BuildPlanMigrationModalEvent::Close);
} else {
// External update - refresh our state to stay in sync
self.refresh_addon_credits_settings(ctx);
ctx.notify();
}
}
UserWorkspacesEvent::UpdateWorkspaceSettingsRejected(_err) => {
self.is_updating = false;
ctx.emit(BuildPlanMigrationModalEvent::ShowToast {
message: "Failed to enable auto-reload. Please try updating your settings in Billing & usage.".to_string(),
flavor: ToastFlavor::Error,
});
ctx.notify();
}
_ => {}
}
}
fn mark_modal_dismissed(ctx: &mut ViewContext<Self>) {
let general_settings = GeneralSettings::handle(ctx);
general_settings.update(ctx, |settings, ctx| {
if let Err(e) = settings
.build_plan_migration_modal_dismissed
.set_value(true, ctx)
{
log::warn!("Failed to set build plan migration modal dismissed setting: {e}");
}
});
}
fn populate_reload_denomination_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
self.reload_denominations_dropdown
.update(ctx, |dropdown, ctx| {
dropdown.set_items(
self.addon_credits_options
.iter()
.enumerate()
.map(|(i, option)| {
DropdownItem::new(
format!(
"${} / {} credits",
option.price_usd_cents / 100,
option.credits.separate_with_commas(),
),
BuildPlanMigrationModalViewAction::SelectReloadDenomination(i),
)
})
.collect(),
ctx,
);
// Set the selected item to match the current selection
dropdown.set_selected_by_index(self.selected_addon_credits_option, ctx);
});
ctx.notify();
}
fn render_auto_reload_controls(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let check_color = theme.background().into_solid();
let auto_reload_enabled = self.auto_reload_enabled;
let checkbox = appearance
.ui_builder()
.checkbox(
self.state_handles.auto_reload_checkbox.clone(),
Some(appearance.ui_font_size()),
)
.check(auto_reload_enabled)
.with_style(UiComponentStyles {
font_color: Some(check_color),
..Default::default()
})
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(
BuildPlanMigrationModalViewAction::EnableAutoReloadToggled(
!auto_reload_enabled,
),
)
})
.finish();
let label = FormattedTextElement::from_str("Auto-reload", appearance.ui_font_family(), 12.)
.with_color(blended_colors::text_sub(
theme,
blended_colors::neutral_4(theme),
))
.finish();
let checkbox_row = Flex::row()
.with_child(checkbox)
.with_child(label)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish();
let dropdown = if self.auto_reload_enabled {
ChildView::new(&self.reload_denominations_dropdown).finish()
} else {
// Match dropdown height to prevent layout shift (dropdown is typically ~28-32px)
ConstrainedBox::new(warpui::elements::Empty::new().finish())
.with_width(DROPDOWN_WIDTH)
.with_height(28.)
.finish()
};
Flex::row()
.with_child(
Container::new(checkbox_row)
.with_vertical_margin(8.)
.with_margin_right(4.)
.finish(),
)
.with_child(dropdown)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.finish()
}
fn render_get_started_button(&self, appearance: &Appearance) -> Box<dyn Element> {
let button_text = if self.is_updating {
"Saving...".to_string()
} else {
"Get Started".to_string()
};
let button_font_color = self.is_updating.then_some(
appearance
.theme()
.disabled_text_color(appearance.theme().surface_3())
.into(),
);
let button_bg_color = self
.is_updating
.then_some(appearance.theme().surface_3().into());
let button_border = self
.is_updating
.then_some(ColorU::transparent_black().into());
let mut button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.state_handles.upgrade_button.clone(),
)
.with_style(UiComponentStyles {
font_size: Some(12.),
height: Some(28.),
width: Some(96.),
font_color: button_font_color,
background: button_bg_color,
border_color: button_border,
..Default::default()
})
.with_centered_text_label(button_text)
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(BuildPlanMigrationModalViewAction::GetStartedClicked)
});
if self.is_updating {
button = button.disable();
}
button.finish()
}
fn render_right_panel_content(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let title = Self::create_text(
"Use auto-reload to never miss a beat.".to_string(),
appearance.ui_font_family(),
16.,
blended_colors::text_main(theme, blended_colors::neutral_2(theme)),
Some(Weight::Bold),
);
let description = Self::create_text(
"Auto-reload will automatically purchase credits at your selected rate when your account balance reaches 100 credits. Your monthly spend limit is set at your legacy plan's monthly cost and can be updated in Settings > Billing & usage.".to_string(),
appearance.ui_font_family(),
14.,
blended_colors::text_sub(theme, blended_colors::neutral_4(theme)),
None,
);
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(
Shrinkable::new(
1.,
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Container::new(title).with_margin_bottom(12.).finish())
.with_child(Container::new(description).with_margin_bottom(16.).finish())
.finish(),
)
.finish(),
)
.with_child(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(self.render_auto_reload_controls(appearance))
.with_child(self.render_get_started_button(appearance))
.finish(),
)
.finish(),
)
.finish()
}
fn render_right_panel(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let image = ConstrainedBox::new(
Image::new(
bundled_or_fetched_asset!("png/build_spiral.png"),
CacheOption::BySize,
)
.cover()
.with_corner_radius(CornerRadius::with_top_right(Radius::Pixels(CORNER_RADIUS)))
.finish(),
)
.with_width(543.)
.with_height(335.)
.finish();
let content_panel = Shrinkable::new(
1.,
Container::new(self.render_right_panel_content(appearance))
.with_uniform_padding(PANEL_PADDING)
.with_background(blended_colors::neutral_2(theme))
.with_border(Border::left(1.).with_border_color(blended_colors::neutral_4(theme)))
.with_corner_radius(CornerRadius::with_bottom_right(Radius::Pixels(
CORNER_RADIUS,
)))
.finish(),
)
.finish();
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_child(image)
.with_child(content_panel)
.finish()
}
}
impl Entity for BuildPlanMigrationModal {
type Event = BuildPlanMigrationModalEvent;
}
const BULLET_WIDTH: f32 = 12.;
impl BuildPlanMigrationModal {
fn create_bullet_item(
text: String,
font_family: FamilyId,
font_size: f32,
color: ColorU,
) -> Box<dyn Element> {
let bullet = FormattedTextElement::from_str("", font_family, font_size)
.with_color(color)
.with_weight(Weight::Bold)
.finish();
let text_content = FormattedTextElement::from_str(text, font_family, font_size)
.with_color(color)
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
ConstrainedBox::new(bullet)
.with_width(BULLET_WIDTH)
.finish(),
)
.with_child(Shrinkable::new(1., text_content).finish())
.finish()
}
}
impl BuildPlanMigrationModal {
fn create_text(
text: String,
font_family: FamilyId,
font_size: f32,
color: ColorU,
weight: Option<Weight>,
) -> Box<dyn Element> {
let mut element =
FormattedTextElement::from_str(text, font_family, font_size).with_color(color);
if let Some(weight) = weight {
element = element.with_weight(weight);
}
element.finish()
}
fn render_left_panel(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let text_color = blended_colors::text_sub(theme, blended_colors::neutral_2(theme));
// Check if any service agreement has type Business
let is_business = UserWorkspaces::as_ref(app)
.current_workspace()
.map(|workspace| workspace.billing_metadata.customer_type == CustomerType::Business)
.unwrap_or(false);
let plan_pricing = PricingInfoModel::as_ref(app).plan_pricing(if is_business {
&StripeSubscriptionPlan::BuildBusiness
} else {
&StripeSubscriptionPlan::Build
});
let base_credits_limit = plan_pricing.and_then(|p| p.request_limit).unwrap_or(1500);
// (monthly price cents, monthly price cents for annual)
let base_plan_prices = plan_pricing
.map(|p| {
(
p.monthly_plan_price_per_month_usd_cents,
p.yearly_plan_price_per_month_usd_cents,
)
})
.unwrap_or((2000, 1800));
let title_text = if is_business {
"Welcome to the New Business Plan"
} else {
"Welcome to Warp Build"
};
let title = Self::create_text(
title_text.to_string(),
font_family,
24.,
blended_colors::text_main(theme, blended_colors::neutral_2(theme)),
Some(Weight::Bold),
);
let intro_text = if is_business {
"Your workspace has been updated to the new Warp Business Plan as the legacy Business plan is sunset."
} else {
"Your workspace has been updated to the Warp Build Plan as the legacy Pro, Turbo, and Lightspeed plans are sunset."
};
let intro = Self::create_text(intro_text.to_string(), font_family, 14., text_color, None);
let pricing_header = Self::create_text(
if is_business {
"The new Business plan is a primarily usage-based plan, starting at:"
} else {
"Warp Build is a primarily usage-based plan, starting at:"
}
.to_string(),
font_family,
14.,
text_color,
None,
);
let price_monthly = Self::create_bullet_item(
format!("${} per user per month", base_plan_prices.0 / 100),
font_family,
14.,
text_color,
);
let price_annual = Self::create_bullet_item(
format!(
"${} per user per month for annual plans",
base_plan_prices.1 / 100
),
font_family,
14.,
text_color,
);
let features_header = Self::create_text(
if is_business {
"The new Business plan comes with:"
} else {
"Build comes with:"
}
.to_string(),
font_family,
14.,
text_color,
None,
);
let base_credits = Self::create_bullet_item(
format!(
"{} base credits per month",
base_credits_limit.separate_with_commas()
),
font_family,
14.,
text_color,
);
let reload_credits = Self::create_bullet_item(
"Access to Reload credits and volume-based discounts".to_string(),
font_family,
14.,
text_color,
);
let byok = Self::create_bullet_item(
"Bring your own API key".to_string(),
font_family,
14.,
text_color,
);
let mut features_list = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(base_credits)
.with_child(reload_credits)
.with_child(byok);
if is_business {
let sso = Self::create_bullet_item(
"SAML-based SSO".to_string(),
font_family,
14.,
text_color,
);
features_list.add_child(sso);
let zdr = Self::create_bullet_item(
"Automatically enforced team-wide Zero Data Retention".to_string(),
font_family,
14.,
text_color,
);
features_list.add_child(zdr);
}
let and_more =
Self::create_bullet_item("And more...".to_string(), font_family, 14., text_color);
features_list.add_child(and_more);
let learn_more_fragments = vec![
FormattedTextFragment::plain_text("Learn more on our "),
FormattedTextFragment::hyperlink("pricing page", "https://www.warp.dev/pricing"),
FormattedTextFragment::plain_text("."),
];
let learn_more = Container::new(
FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(learn_more_fragments)]),
14.,
font_family,
font_family,
text_color,
HighlightedHyperlink::default(),
)
.with_hyperlink_font_color(appearance.theme().accent().into_solid())
.register_default_click_handlers(|_url, ctx, _| {
ctx.dispatch_typed_action(BuildPlanMigrationModalViewAction::OpenUrl(
"https://www.warp.dev/pricing",
));
})
.finish(),
)
.finish();
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Container::new(title).with_margin_bottom(12.).finish())
.with_child(Container::new(intro).with_margin_bottom(16.).finish())
.with_child(
Container::new(pricing_header)
.with_margin_bottom(8.)
.finish(),
)
.with_child(price_monthly)
.with_child(
Container::new(price_annual)
.with_margin_bottom(16.)
.finish(),
)
.with_child(
Container::new(features_header)
.with_margin_bottom(8.)
.finish(),
)
.with_child(
Container::new(features_list.finish())
.with_margin_bottom(16.)
.finish(),
)
.with_child(Container::new(learn_more).finish())
.finish(),
)
.with_background_color(blended_colors::neutral_1(theme))
.with_corner_radius(CornerRadius::with_left(Radius::Pixels(CORNER_RADIUS)))
.with_uniform_padding(PANEL_PADDING)
.finish()
}
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
appearance
.ui_builder()
.close_button(BUTTON_DIAMETER, self.state_handles.close_button.clone())
.with_style(UiComponentStyles {
font_color: Some(ColorU::white()),
..Default::default()
})
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(BuildPlanMigrationModalViewAction::Close)
})
.finish()
}
}
impl View for BuildPlanMigrationModal {
fn ui_name() -> &'static str {
"BuildPlanMigrationModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let left_panel = self.render_left_panel(appearance, app);
let close_button = self.render_close_button(appearance);
let right_panel_width = MODAL_WIDTH - LEFT_PANEL_WIDTH;
let mut modal = Stack::new();
modal.add_child(
Container::new(
ConstrainedBox::new(
Flex::row()
.with_child(
ConstrainedBox::new(left_panel)
.with_width(LEFT_PANEL_WIDTH)
.with_height(MODAL_HEIGHT)
.finish(),
)
.with_child(
ConstrainedBox::new(self.render_right_panel(app))
.with_width(right_panel_width)
.with_height(MODAL_HEIGHT)
.finish(),
)
.finish(),
)
.with_width(MODAL_WIDTH)
.with_height(MODAL_HEIGHT)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_drop_shadow(DropShadow::default())
.finish(),
);
modal.add_positioned_child(
close_button,
OffsetPositioning::offset_from_parent(
vec2f(-14., 14.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
// Stack needed so that modal can get bounds information,
// specifically to ensure no overlap with the window's traffic lights
let mut stack = Stack::new();
stack.add_positioned_child(
modal.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
Container::new(Align::new(stack.finish()).finish())
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
.finish()
}
}
impl TypedActionView for BuildPlanMigrationModal {
type Action = BuildPlanMigrationModalViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
BuildPlanMigrationModalViewAction::SelectReloadDenomination(index) => {
self.selected_addon_credits_option = *index;
ctx.notify();
}
BuildPlanMigrationModalViewAction::GetStartedClicked => {
// Get current team UID and workspace data
let workspaces = UserWorkspaces::as_ref(ctx);
let Some(team_uid) = workspaces.current_team_uid() else {
ctx.emit(BuildPlanMigrationModalEvent::ShowToast {
message: "Oops, something went wrong; your team data could not be found."
.to_string(),
flavor: ToastFlavor::Error,
});
return;
};
// Get current monthly spend limit before any mutable borrows
let current_monthly_spend_limit = workspaces
.current_workspace()
.and_then(|ws| ws.settings.addon_credits_settings.max_monthly_spend_cents);
// Set loading state
self.is_updating = true;
ctx.notify();
// Determine selected denomination (only if auto-reload is enabled)
let selected_denomination = if self.auto_reload_enabled {
self.addon_credits_options
.get(self.selected_addon_credits_option)
.map(|option| option.credits)
} else {
None
};
// Determine if we need to update the monthly spend limit
// If the selected denomination price is greater than the current limit, increase the limit
let new_monthly_spend_limit = if self.auto_reload_enabled {
self.addon_credits_options
.get(self.selected_addon_credits_option)
.and_then(|option| {
let selected_price = option.price_usd_cents;
match current_monthly_spend_limit {
Some(current_limit) if selected_price > current_limit => {
Some(selected_price)
}
None => Some(selected_price),
_ => None,
}
})
} else {
None
};
// Call API to update auto-reload settings
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
user_workspaces.update_addon_credits_settings(
team_uid,
Some(self.auto_reload_enabled),
new_monthly_spend_limit,
selected_denomination,
ctx,
);
});
}
BuildPlanMigrationModalViewAction::Close => {
Self::mark_modal_dismissed(ctx);
ctx.emit(BuildPlanMigrationModalEvent::Close);
}
BuildPlanMigrationModalViewAction::EnableAutoReloadToggled(enabled) => {
self.auto_reload_enabled = *enabled;
ctx.notify();
}
BuildPlanMigrationModalViewAction::OpenUrl(url) => {
ctx.open_url(url);
}
}
}
}
#[derive(Clone, Debug)]
pub enum BuildPlanMigrationModalEvent {
Close,
ShowToast {
message: String,
flavor: ToastFlavor,
},
}
@@ -0,0 +1,455 @@
use crate::auth::AuthStateProvider;
use crate::pricing::PricingInfoModel;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::CustomerType;
use asset_macro::bundled_or_fetched_asset;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use thousands::Separable;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::Fill;
use warp_graphql::billing::StripeSubscriptionPlan;
use warpui::elements::{
Align, CacheOption, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DropShadow, Expanded, Flex, FormattedTextElement, HighlightedHyperlink, Image,
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack,
};
use warpui::fonts::Weight;
use warpui::keymap::FixedBinding;
use warpui::platform::Cursor;
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use crate::send_telemetry_from_ctx;
use crate::TelemetryEvent;
const MODAL_WIDTH: f32 = 360.;
const MODAL_HEIGHT: f32 = 532.;
const COMPACT_MODAL_HEIGHT: f32 = 360.;
const HEADER_HEIGHT: f32 = 92.;
const BUTTON_DIAMETER: f32 = 20.;
const BILLING_AND_USAGE_URL: &str = "warp://settings/billing_and_usage";
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub enum CloudAgentCapacityModalVariant {
#[default]
ConcurrentLimit,
OutOfCredits,
}
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
CloudAgentCapacityModalAction::Close,
id!("CloudAgentCapacityModal"),
)]);
}
#[derive(Default)]
struct StateHandles {
close_button: MouseStateHandle,
upgrade_button: MouseStateHandle,
}
pub struct CloudAgentCapacityModal {
state_handles: StateHandles,
variant: CloudAgentCapacityModalVariant,
}
impl CloudAgentCapacityModal {
pub fn new() -> Self {
CloudAgentCapacityModal {
state_handles: Default::default(),
variant: CloudAgentCapacityModalVariant::default(),
}
}
pub fn set_variant(&mut self, variant: CloudAgentCapacityModalVariant) {
self.variant = variant;
}
fn get_upgrade_url(ctx: &ViewContext<Self>) -> Option<String> {
let auth_state = AuthStateProvider::handle(ctx).as_ref(ctx).get();
if let Some(team) = UserWorkspaces::handle(ctx).as_ref(ctx).current_team() {
return Some(UserWorkspaces::upgrade_link_for_team(team.uid));
}
let user_id = auth_state.user_id().unwrap_or_default();
Some(UserWorkspaces::upgrade_link(user_id))
}
fn can_upgrade(customer_type: CustomerType, variant: CloudAgentCapacityModalVariant) -> bool {
match variant {
CloudAgentCapacityModalVariant::ConcurrentLimit => !matches!(
customer_type,
CustomerType::Business | CustomerType::Enterprise
),
CloudAgentCapacityModalVariant::OutOfCredits => {
matches!(customer_type, CustomerType::Free | CustomerType::Unknown)
}
}
}
fn should_show_cta(
customer_type: CustomerType,
variant: CloudAgentCapacityModalVariant,
) -> bool {
matches!(variant, CloudAgentCapacityModalVariant::OutOfCredits)
|| Self::can_upgrade(customer_type, variant)
}
fn cta_url(&self, ctx: &ViewContext<Self>) -> Option<String> {
let customer_type = UserWorkspaces::handle(ctx)
.as_ref(ctx)
.current_workspace()
.map(|workspace| workspace.billing_metadata.customer_type)
.unwrap_or(CustomerType::Free);
if !Self::should_show_cta(customer_type, self.variant) {
return None;
}
if Self::can_upgrade(customer_type, self.variant) {
Self::get_upgrade_url(ctx)
} else {
Some(BILLING_AND_USAGE_URL.to_string())
}
}
fn render_content(&self, customer_type: CustomerType, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::handle(app).as_ref(app);
let theme = appearance.theme();
let neutral_bg = blended_colors::neutral_1(theme);
let (title_text, mut explanation_text) = match self.variant {
CloudAgentCapacityModalVariant::ConcurrentLimit => (
"Concurrent cloud agent limit reached",
"This cloud run is queued because your team has reached the maximum number of concurrent cloud agents. It will start automatically when another cloud run finishes.".to_string(),
),
CloudAgentCapacityModalVariant::OutOfCredits => (
"You're out of AI credits",
"This cloud run stopped because your team has used all available AI credits for the current billing period.".to_string(),
),
};
// Title
let title = FormattedTextElement::from_str(title_text, appearance.ui_font_family(), 24.)
.with_color(blended_colors::text_main(theme, neutral_bg))
.with_weight(Weight::Bold)
.finish();
// Explanation.
let can_upgrade = Self::can_upgrade(customer_type, self.variant);
let show_cta = Self::should_show_cta(customer_type, self.variant);
if can_upgrade {
let upgrade_suffix = match self.variant {
CloudAgentCapacityModalVariant::ConcurrentLimit => {
" Upgrade your plan for more concurrent cloud agents."
}
CloudAgentCapacityModalVariant::OutOfCredits => {
" Upgrade your plan to continue running cloud agents."
}
};
explanation_text.push_str(upgrade_suffix);
}
let subtitle =
FormattedTextElement::from_str(explanation_text, appearance.ui_font_family(), 14.)
.with_color(blended_colors::text_sub(theme, neutral_bg))
.finish();
let mut content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Container::new(title).with_margin_bottom(12.).finish())
.with_child(Container::new(subtitle).with_margin_bottom(16.).finish());
if can_upgrade {
let (target_plan, agent_multiplier, extra_benefits) = match customer_type {
CustomerType::Build | CustomerType::BuildMax => {
(StripeSubscriptionPlan::BuildBusiness, "2x", vec!["SSO"])
}
// Free tier or a legacy plan.
_ => (StripeSubscriptionPlan::Build, "5x", vec![]),
};
let plan_pricing = PricingInfoModel::handle(app)
.as_ref(app)
.plan_pricing(&target_plan);
// Pricing text based on plan type and actual pricing
let pricing_text = if customer_type == CustomerType::Free {
if let Some(pricing) = plan_pricing {
let price = pricing.yearly_plan_price_per_month_usd_cents / 100;
format!(
"Paid plans start at ${price}/month and include everything in your free trial plus:"
)
} else {
"Paid plans include everything in your free trial plus:".to_string()
}
} else if let Some(pricing) = plan_pricing {
let price = pricing.yearly_plan_price_per_month_usd_cents / 100;
format!(
"The Business plan starts at ${price}/month and includes everything on your current plan plus:"
)
} else {
"The Business plan includes everything on your current plan plus:".to_string()
};
let pricing = FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(pricing_text),
])]),
14.,
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_sub(theme, neutral_bg),
HighlightedHyperlink::default(),
)
.finish();
// Credits text from plan pricing
let credits_text = if let Some(limit) = plan_pricing.and_then(|plan| plan.request_limit)
{
format!("{} AI credits per month", limit.separate_with_commas())
} else {
"Extended AI credits per month".to_string()
};
// Benefits list based on plan type
let mut benefits = vec![
format!("{} the number of concurrent cloud agents", agent_multiplier),
credits_text,
"Bring your own API key".to_string(),
];
for extra in extra_benefits {
benefits.push(extra.to_string());
}
let mut benefits_column =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Start);
for benefit in benefits {
let benefit_formatted = FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(benefit),
])]);
benefits_column.add_child(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
Icon::CheckCircleBroken
.to_warpui_icon(Fill::Solid(theme.ansi_fg_green()))
.finish(),
)
.with_width(14.)
.with_height(14.)
.finish(),
)
.with_margin_right(4.)
.finish(),
)
.with_child(
FormattedTextElement::new(
benefit_formatted,
14.,
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_sub(theme, neutral_bg),
HighlightedHyperlink::default(),
)
.finish(),
)
.finish(),
)
.with_margin_bottom(8.)
.finish(),
);
}
content.add_child(Container::new(pricing).with_margin_bottom(8.).finish());
content.add_child(benefits_column.finish());
}
let content = content.finish();
let cta_button = if show_cta {
let cta_button_label = if can_upgrade {
"Upgrade plan"
} else {
"Open billing"
};
Some(
appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.state_handles.upgrade_button.clone(),
)
.with_style(UiComponentStyles {
font_size: Some(14.),
height: Some(32.),
width: Some(296.),
..Default::default()
})
.with_centered_text_label(cta_button_label.to_string())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(CloudAgentCapacityModalAction::Upgrade)
})
.finish(),
)
} else {
None
};
// Main content layout
let layout = if let Some(cta_button) = cta_button {
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(content)
.with_child(Align::new(cta_button).bottom_left().finish())
.finish()
} else {
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(content)
.finish()
};
Container::new(layout).with_uniform_padding(32.).finish()
}
fn render_header() -> Box<dyn Element> {
ConstrainedBox::new(
Image::new(
bundled_or_fetched_asset!("png/concurrency_limit_header.png"),
CacheOption::BySize,
)
.cover()
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(10.)))
.finish(),
)
.with_width(MODAL_WIDTH)
.with_height(HEADER_HEIGHT)
.finish()
}
}
impl Entity for CloudAgentCapacityModal {
type Event = CloudAgentCapacityModalEvent;
}
impl View for CloudAgentCapacityModal {
fn ui_name() -> &'static str {
"CloudAgentCapacityModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::handle(app).as_ref(app);
let theme = appearance.theme();
let close_button = appearance
.ui_builder()
.close_button(BUTTON_DIAMETER, self.state_handles.close_button.clone())
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(CloudAgentCapacityModalAction::Close))
.finish();
let customer_type = UserWorkspaces::as_ref(app)
.current_workspace()
.map(|workspace| workspace.billing_metadata.customer_type)
.unwrap_or(CustomerType::Free);
let can_upgrade = Self::can_upgrade(customer_type, self.variant);
let mut modal = Stack::new();
modal.add_child(
Container::new(
ConstrainedBox::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_child(Self::render_header())
.with_child(
Expanded::new(1., self.render_content(customer_type, app)).finish(),
)
.finish(),
)
.with_width(MODAL_WIDTH)
.with_height(if can_upgrade {
MODAL_HEIGHT
} else {
COMPACT_MODAL_HEIGHT
})
.finish(),
)
.with_background_color(blended_colors::neutral_1(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.)))
.with_drop_shadow(DropShadow::default())
.finish(),
);
modal.add_positioned_child(
close_button,
OffsetPositioning::offset_from_parent(
vec2f(-8., 8.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
let mut stack = Stack::new();
stack.add_positioned_child(
modal.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
// Semi-transparent backdrop overlay
Container::new(Align::new(stack.finish()).finish())
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
.finish()
}
}
impl TypedActionView for CloudAgentCapacityModal {
type Action = CloudAgentCapacityModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
CloudAgentCapacityModalAction::Close => {
send_telemetry_from_ctx!(TelemetryEvent::CloudAgentCapacityModalDismissed, ctx);
ctx.emit(CloudAgentCapacityModalEvent::Close);
}
CloudAgentCapacityModalAction::Upgrade => {
if let Some(upgrade_url) = self.cta_url(ctx) {
send_telemetry_from_ctx!(
TelemetryEvent::CloudAgentCapacityModalUpgradeClicked,
ctx
);
ctx.open_url(&upgrade_url);
ctx.emit(CloudAgentCapacityModalEvent::Close);
}
}
}
}
}
#[derive(Copy, Clone, Debug)]
pub enum CloudAgentCapacityModalEvent {
Close,
}
#[derive(Clone, Debug)]
pub enum CloudAgentCapacityModalAction {
Close,
Upgrade,
}
+290
View File
@@ -0,0 +1,290 @@
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ActionButtonTheme};
use asset_macro::bundled_or_fetched_asset;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::Fill;
use warpui::elements::{
Align, Border, CacheOption, ChildAnchor, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DropShadow, Flex, FormattedTextElement, Image, MainAxisAlignment,
MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Stack, Text,
};
use warpui::fonts::Weight;
use warpui::keymap::FixedBinding;
use warpui::presenter::ChildView;
use warpui::ui_components::components::UiComponent;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
/// White button theme for the Codex modal CTA.
struct WhiteButtonTheme;
impl ActionButtonTheme for WhiteButtonTheme {
fn background(&self, hovered: bool, _appearance: &Appearance) -> Option<Fill> {
if hovered {
Some(Fill::Solid(ColorU::new(230, 230, 230, 255)))
} else {
Some(Fill::Solid(ColorU::new(255, 255, 255, 255)))
}
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
_appearance: &Appearance,
) -> ColorU {
ColorU::new(0, 0, 0, 255)
}
}
const BUTTON_DIAMETER: f32 = 20.;
const MODAL_HEIGHT: f32 = 395.;
const LEFT_PANEL_WIDTH: f32 = 330.;
const RIGHT_PANEL_WIDTH: f32 = 325.;
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
CodexModalAction::Close,
id!("CodexModal"),
)]);
}
#[derive(Default)]
struct StateHandles {
close_button: MouseStateHandle,
}
pub struct CodexModal {
state_handles: StateHandles,
cta_button: ViewHandle<ActionButton>,
}
impl CodexModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let cta_button = ctx.add_view(|_| {
ActionButton::new("Use latest codex model", WhiteButtonTheme)
.with_icon(Icon::OpenAILogo)
.with_full_width(true)
.on_click(|ctx| {
ctx.dispatch_typed_action(CodexModalAction::UseCodex);
})
});
CodexModal {
state_handles: Default::default(),
cta_button,
}
}
fn render_new_badge(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
// Magenta/pink color for the badge
let magenta: ColorU = theme.terminal_colors().normal.magenta.into();
Container::new(
Text::new("New", appearance.ui_font_family(), 12.)
.with_color(magenta)
.finish(),
)
.with_vertical_padding(4.)
.with_horizontal_padding(10.)
.with_background(Fill::Solid(magenta).with_opacity(15))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(12.)))
.finish()
}
fn render_left_panel(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
// "New" badge
let new_badge = self.render_new_badge(appearance);
// Title
let title = FormattedTextElement::from_str(
"Use Codex models in Warp",
appearance.ui_font_family(),
24.,
)
.with_color(blended_colors::text_main(
theme,
blended_colors::neutral_1(theme),
))
.with_weight(Weight::Bold)
.finish();
// Description - first paragraph
let description_1 = FormattedTextElement::from_str(
"Codex is OpenAI's most advanced agentic coding model for real-world engineering.",
appearance.ui_font_family(),
14.,
)
.with_color(blended_colors::text_sub(
theme,
blended_colors::neutral_1(theme),
))
.finish();
// Description - second paragraph
let description_2 = FormattedTextElement::from_str(
"Use Codex directly in Oz and leverage \
features like in-app code review, agent session sharing and file editing.",
appearance.ui_font_family(),
14.,
)
.with_color(blended_colors::text_sub(
theme,
blended_colors::neutral_1(theme),
))
.finish();
// Left panel content
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Container::new(new_badge).with_margin_bottom(16.).finish())
.with_child(Container::new(title).with_margin_bottom(16.).finish())
.with_child(
Container::new(description_1)
.with_margin_bottom(12.)
.finish(),
)
.with_child(description_2)
.finish(),
)
.with_child(ChildView::new(&self.cta_button).finish())
.finish(),
)
.with_background_color(blended_colors::neutral_1(theme))
.with_corner_radius(CornerRadius::with_left(Radius::Pixels(10.)))
.with_uniform_padding(24.)
.finish()
}
fn render_right_panel(&self) -> Box<dyn Element> {
ConstrainedBox::new(
Image::new(
bundled_or_fetched_asset!("png/codex_integration.png"),
CacheOption::BySize,
)
.with_corner_radius(CornerRadius::with_right(Radius::Pixels(10.)))
.finish(),
)
.with_width(RIGHT_PANEL_WIDTH)
.with_height(MODAL_HEIGHT)
.finish()
}
}
impl Entity for CodexModal {
type Event = CodexModalEvent;
}
impl View for CodexModal {
fn ui_name() -> &'static str {
"CodexModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
// Close button
let close_button = appearance
.ui_builder()
.close_button(BUTTON_DIAMETER, self.state_handles.close_button.clone())
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(CodexModalAction::Close))
.finish();
// Modal with two panels
let mut modal = Stack::new();
modal.add_child(
Container::new(
ConstrainedBox::new(
Flex::row()
.with_child(
ConstrainedBox::new(self.render_left_panel(app))
.with_width(LEFT_PANEL_WIDTH)
.with_height(MODAL_HEIGHT)
.finish(),
)
.with_child(self.render_right_panel())
.finish(),
)
.with_width(LEFT_PANEL_WIDTH + RIGHT_PANEL_WIDTH)
.with_height(MODAL_HEIGHT)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.)))
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_drop_shadow(DropShadow::default())
.finish(),
);
modal.add_positioned_child(
close_button,
OffsetPositioning::offset_from_parent(
vec2f(-8., 8.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
// Center the modal in the window
let mut stack = Stack::new();
stack.add_positioned_child(
modal.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
// Background overlay
Container::new(Align::new(stack.finish()).finish())
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
.finish()
}
}
impl TypedActionView for CodexModal {
type Action = CodexModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
CodexModalAction::Close => {
ctx.emit(CodexModalEvent::Close);
}
CodexModalAction::UseCodex => {
ctx.emit(CodexModalEvent::UseCodex);
}
}
}
}
#[derive(Copy, Clone, Debug)]
pub enum CodexModalEvent {
Close,
UseCodex,
}
#[derive(Copy, Clone, Debug)]
pub enum CodexModalAction {
Close,
UseCodex,
}
@@ -0,0 +1,442 @@
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::active_agent_views_model::ConversationOrTaskId;
use crate::ai::agent_conversations_model::ConversationOrTask;
use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING};
use crate::appearance::Appearance;
use crate::drive::sharing::dialog::SharingDialog;
use crate::menu::Menu;
use crate::ui_components::icons::Icon;
use crate::ui_components::menu_button::{icon_button_with_context_menu, MenuDirection};
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::util::truncation::truncate_from_end;
use crate::workspace::view::conversation_list::view::ConversationListViewAction;
use pathfinder_geometry::vector::vec2f;
use warp_core::ui::color::coloru_with_opacity;
use warp_core::ui::theme::color::internal_colors;
use warp_util::path::user_friendly_path;
use warpui::elements::{
AnchorPair, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex, Highlight, Hoverable,
MainAxisAlignment, MainAxisSize, MouseInBehavior, MouseStateHandle, OffsetPositioning,
OffsetType, ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementOffsetBounds,
PositioningAxis, Radius, SavePosition, Shrinkable, Stack, Text, XAxisAnchor, YAxisAnchor,
};
use warpui::fonts::{Properties, Weight};
use warpui::platform::Cursor;
use warpui::text_layout::TextStyle;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{AppContext, SingletonEntity, ViewHandle};
/// Maximum length for tooltip text before truncation
const MAX_TOOLTIP_LENGTH: usize = 80;
/// Spacing between icon and title
const ICON_SPACING: f32 = 4.;
/// Offset for the sharing dialog from the item row
const DIALOG_OFFSET_PIXELS: f32 = -16.;
/// Generate a position ID for a conversation list item
fn conversation_item_position_id(id: &ConversationOrTaskId) -> String {
match id {
ConversationOrTaskId::ConversationId(conv_id) => {
format!("conversation_list_item_{conv_id}")
}
ConversationOrTaskId::TaskId(task_id) => format!("conversation_list_task_{task_id}"),
}
}
/// Minimum height for static list items (section headers, StartNewConversation).
/// Ensures UniformList uses consistent item heights (and doesn't clip any items).
pub const STATIC_ITEM_MIN_HEIGHT: f32 = 42.;
#[derive(Clone, Default)]
pub struct ItemState {
pub mouse_state: MouseStateHandle,
pub overflow_button_state: MouseStateHandle,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum OverflowMenuDisplay {
Closed,
/// Menu was opened from the kebab button.
OpenAtKebab,
/// Menu was opened from a right click (at the click position).
OpenAtRightClickPosition,
}
pub struct ItemProps<'a> {
pub conversation: &'a ConversationOrTask<'a>,
pub highlight_indices: Option<&'a Vec<usize>>,
pub is_selected: bool,
pub is_focused_conversation: bool,
pub index: usize,
pub state: &'a ItemState,
pub overflow_menu: &'a ViewHandle<Menu<ConversationListViewAction>>,
pub overflow_menu_display: OverflowMenuDisplay,
pub conversation_id: ConversationOrTaskId,
pub sharing_dialog: &'a ViewHandle<SharingDialog>,
pub is_share_dialog_open: bool,
pub list_position_id: &'a str,
pub tooltip_opens_right: bool,
}
pub struct StaticItemProps<'a> {
pub is_selected: bool,
pub index: usize,
pub state: &'a ItemState,
}
pub fn render_static_item(props: StaticItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
let StaticItemProps {
is_selected,
index,
state,
} = props;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let icon_color = theme.main_text_color(theme.background());
let icon = Container::new(
ConstrainedBox::new(Icon::Plus.to_warpui_icon(icon_color).finish())
.with_width(appearance.ui_font_size())
.with_height(appearance.ui_font_size())
.finish(),
)
.with_uniform_padding(STATUS_ELEMENT_PADDING)
.with_background(coloru_with_opacity(icon_color.into(), 10))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish();
let title_text = Text::new_inline(
"New conversation",
appearance.ui_font_family(),
appearance.ui_font_size() + 2.,
)
.with_color(theme.main_text_color(theme.background()).into())
.finish();
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(ICON_SPACING)
.with_child(icon)
.with_child(title_text)
.finish();
let hoverable = Hoverable::new(state.mouse_state.clone(), move |_| {
let mut container = Container::new(row).with_horizontal_padding(12.);
if is_selected {
container = container.with_background(theme.surface_overlay_1());
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ConversationListViewAction::NewConversationInNewTab);
});
EventHandler::new(
ConstrainedBox::new(hoverable.finish())
.with_min_height(STATIC_ITEM_MIN_HEIGHT)
.finish(),
)
.on_mouse_in(
move |ctx, _, _| {
ctx.dispatch_typed_action(ConversationListViewAction::SetSelectedIndex(index));
DispatchEventResult::PropagateToParent
},
Some(MouseInBehavior {
fire_on_synthetic_events: false,
fire_when_covered: true,
}),
)
.finish()
}
pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
let ItemProps {
conversation,
highlight_indices,
is_selected,
is_focused_conversation,
index,
state,
overflow_menu,
overflow_menu_display,
conversation_id,
sharing_dialog,
is_share_dialog_open,
list_position_id,
tooltip_opens_right,
} = props;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let ui_builder = appearance.ui_builder().clone();
let font_family = appearance.ui_font_family();
let font_size = appearance.ui_font_size();
let title_font_size = font_size + 2.;
let mut title_text = Text::new_inline(conversation.title(app), font_family, title_font_size)
.with_color(theme.main_text_color(theme.background()).into());
if let Some(indices) = highlight_indices {
if !indices.is_empty() {
let highlight = Highlight::new()
.with_properties(Properties::default().weight(Weight::Bold))
.with_text_style(
TextStyle::new()
.with_foreground_color(theme.main_text_color(theme.background()).into())
.with_background_color(
internal_colors::accent_overlay_3(theme).into_solid(),
),
);
title_text = title_text.with_single_highlight(highlight, indices.clone());
}
}
let status_element_size = font_size + STATUS_ELEMENT_PADDING * 2.;
let icon_element: Box<dyn Element> = if conversation.is_ambient_agent_conversation() {
ConstrainedBox::new(
Icon::Cloud
.to_warpui_icon(theme.sub_text_color(theme.background()))
.finish(),
)
.with_width(status_element_size)
.with_height(status_element_size)
.finish()
} else {
render_status_element(&conversation.status(app), font_size, appearance)
};
let icon_and_title_row = Shrinkable::new(
1.0,
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(ICON_SPACING)
.with_child(icon_element)
.with_child(Shrinkable::new(1.0, title_text.finish()).finish())
.finish(),
)
.finish();
let timestamp = Text::new_inline(
format_approx_duration_from_now_utc(conversation.last_updated()),
font_family,
font_size - 2.,
)
.with_color(theme.sub_text_color(theme.background()).into())
.finish();
let bottom_row = if let Some(subtext) = format_item_subtext(conversation, app) {
let subtext_element = Shrinkable::new(
1.0,
Text::new_inline(subtext, font_family, title_font_size - 2.)
.with_color(theme.sub_text_color(theme.background()).into())
.finish(),
)
.finish();
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::End)
.with_child(subtext_element)
.with_child(timestamp)
.finish(),
)
.with_padding_left(status_element_size + ICON_SPACING)
.finish()
} else {
// If no subtext, still show timestamp in the bottom row
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_child(timestamp)
.finish(),
)
.with_padding_left(status_element_size + ICON_SPACING)
.finish()
};
let row = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(icon_and_title_row)
.with_child(bottom_row)
.finish();
// Use shared logic from ConversationOrTask to determine open action
let open_action = conversation.get_open_action(None, app);
let title = conversation.title(app);
let tooltip_text = truncate_from_end(&title, MAX_TOOLTIP_LENGTH);
let overflow_button_state = state.overflow_button_state.clone();
let hoverable = Hoverable::new(state.mouse_state.clone(), move |_| {
let container = Container::new(row)
.with_horizontal_padding(12.)
.with_padding_top(8.);
let container = if is_focused_conversation {
container.with_background(theme.surface_overlay_2())
} else if is_selected || !matches!(overflow_menu_display, OverflowMenuDisplay::Closed) {
container.with_background(theme.surface_overlay_1())
} else {
container
};
let mut stack = Stack::new().with_child(container.finish());
// We show the overflow menu button when the item is selected, or the overflow menu is already open.
if is_selected || !matches!(overflow_menu_display, OverflowMenuDisplay::Closed) {
let button_style = UiComponentStyles::default()
.set_background(theme.surface_2().into())
.set_border_color(theme.surface_3().into());
let menu_direction = if tooltip_opens_right {
MenuDirection::Right
} else {
MenuDirection::Left
};
let overflow_button = icon_button_with_context_menu(
Icon::DotsVertical,
move |ctx, _, _| {
ctx.dispatch_typed_action(ConversationListViewAction::ToggleOverflowMenu {
conversation_id,
position: None,
});
},
overflow_button_state.clone(),
overflow_menu,
matches!(overflow_menu_display, OverflowMenuDisplay::OpenAtKebab),
menu_direction,
Some(Cursor::PointingHand),
Some(button_style),
appearance,
);
let (parent_anchor, child_anchor, offset_x) = if tooltip_opens_right {
(ParentAnchor::TopRight, ChildAnchor::TopRight, -8.)
} else {
(ParentAnchor::TopLeft, ChildAnchor::TopLeft, 8.)
};
let overflow_offset = OffsetPositioning::offset_from_parent(
vec2f(offset_x, 6.),
ParentOffsetBounds::ParentByPosition,
parent_anchor,
child_anchor,
);
// Use add_positioned_child (not overlay) so button stays within item bounds
stack.add_positioned_child(overflow_button.finish(), overflow_offset);
}
// Hide the tooltip when the overflow menu is being shown so that they don't overlap.
if is_selected && matches!(overflow_menu_display, OverflowMenuDisplay::Closed) {
let tooltip = ui_builder.tool_tip(tooltip_text).build().finish();
let (parent_anchor, child_anchor, offset_x) = if tooltip_opens_right {
(ParentAnchor::MiddleRight, ChildAnchor::MiddleLeft, 4.)
} else {
(ParentAnchor::MiddleLeft, ChildAnchor::MiddleRight, -4.)
};
let tooltip_offset = OffsetPositioning::offset_from_parent(
vec2f(offset_x, 0.),
ParentOffsetBounds::WindowByPosition,
parent_anchor,
child_anchor,
);
stack.add_positioned_overlay_child(tooltip, tooltip_offset);
}
stack.finish()
})
.on_right_click({
let list_position_id = list_position_id.to_string();
move |ctx, _, position| {
let Some(parent_bounds) = ctx.element_position_by_id(&list_position_id) else {
log::warn!("Could not retreive the position of the conversation list for overflow menu display.");
return;
};
let offset = position - parent_bounds.origin();
ctx.dispatch_typed_action(ConversationListViewAction::ToggleOverflowMenu {
conversation_id,
position: Some(offset),
});
}
})
.with_defer_events_to_children();
let hoverable_element = if open_action.is_some() {
hoverable
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ConversationListViewAction::OpenItem {
id: conversation_id,
});
})
.finish()
} else {
hoverable.finish()
};
let event_handler = EventHandler::new(hoverable_element)
.on_mouse_in(
move |ctx, _, _| {
ctx.dispatch_typed_action(ConversationListViewAction::SetSelectedIndex(index));
DispatchEventResult::PropagateToParent
},
Some(MouseInBehavior {
fire_on_synthetic_events: false,
fire_when_covered: true,
}),
)
.finish();
// Wrap in a stack to support the sharing dialog overlay
let position_id = conversation_item_position_id(&conversation_id);
let mut item_stack = Stack::new().with_child(event_handler);
// Add the sharing dialog as a positioned overlay when open for this item
if is_share_dialog_open {
// Position the dialog to the right of the item row
item_stack.add_positioned_overlay_child(
ChildView::new(sharing_dialog).finish(),
OffsetPositioning::from_axes(
PositioningAxis::relative_to_stack_child(
&position_id,
PositionedElementOffsetBounds::WindowBySize,
OffsetType::Pixel(DIALOG_OFFSET_PIXELS),
AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Left),
),
PositioningAxis::relative_to_stack_child(
&position_id,
PositionedElementOffsetBounds::WindowByPosition,
OffsetType::Pixel(DIALOG_OFFSET_PIXELS),
AnchorPair::new(YAxisAnchor::Middle, YAxisAnchor::Middle),
),
),
);
}
SavePosition::new(item_stack.finish(), &position_id).finish()
}
/// Returns the secondary label for a conversation list item:
/// - For local conversations: the working directory.
/// - For tasks: the source (Linear, Slack, CLI, etc.)
fn format_item_subtext(conversation: &ConversationOrTask, app: &AppContext) -> Option<String> {
match conversation {
ConversationOrTask::Task(task) => {
task.source.as_ref().map(|s| s.display_name().to_string())
}
ConversationOrTask::Conversation(metadata) => {
// If this conversation is active (with an expanded agent view),
// we use the terminal session's live working directory.
let live_pwd = ActiveAgentViewsModel::as_ref(app)
.get_active_session_for_conversation(metadata.nav_data.id, app)
.and_then(|session| session.as_ref(app).current_working_directory().cloned());
let pwd = live_pwd.or_else(|| metadata.nav_data.initial_working_directory.clone());
pwd.map(|pwd| {
let home_dir = dirs::home_dir().and_then(|p| p.to_str().map(String::from));
user_friendly_path(&pwd, home_dir.as_deref()).into_owned()
})
}
}
}
@@ -0,0 +1,3 @@
mod item;
pub mod view;
mod view_model;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,194 @@
use crate::ai::active_agent_views_model::ConversationOrTaskId;
use crate::ai::agent_conversations_model::{
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter,
ConversationOrTask, CreatedOnFilter, CreatorFilter, OwnerFilter, SessionStatus, SourceFilter,
StatusFilter,
};
use fuzzy_match::match_indices_case_insensitive;
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
pub struct ConversationListViewModelEvent;
#[derive(Clone, Debug)]
pub struct ConversationEntry {
pub id: ConversationOrTaskId,
pub highlight_indices: Vec<usize>,
}
pub struct ConversationListViewModel {
conversations_model: ModelHandle<AgentConversationsModel>,
cached_conversation_or_task_ids: Vec<ConversationOrTaskId>,
filtered_items: Vec<ConversationEntry>,
search_query: String,
}
impl Entity for ConversationListViewModel {
type Event = ConversationListViewModelEvent;
}
impl ConversationListViewModel {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let conversations_model = AgentConversationsModel::handle(ctx);
ctx.subscribe_to_model(&conversations_model, |me, event, ctx| {
match event {
// These events change the set of items in the list, so we need
// to rebuild the cached ID list.
AgentConversationsModelEvent::ConversationsLoaded
| AgentConversationsModelEvent::NewTasksReceived
| AgentConversationsModelEvent::TasksUpdated
| AgentConversationsModelEvent::TaskManuallyOpened => {
me.refresh_cached_items(ctx);
}
// Status changes don't affect the set of IDs (status is read
// at render time via get_item_by_id); just signal a re-render.
AgentConversationsModelEvent::ConversationUpdated => {
ctx.emit(ConversationListViewModelEvent);
}
// Artifact updates don't affect the conversation list
AgentConversationsModelEvent::ConversationArtifactsUpdated { .. } => {}
}
});
let mut model = Self {
conversations_model,
cached_conversation_or_task_ids: Vec::new(),
filtered_items: Vec::new(),
search_query: String::new(),
};
model.refresh_cached_items(ctx);
model
}
/// Rebuilds the cached list of IDs from the current task/conversation set.
///
/// The cache stores only `ConversationOrTaskId`s; per-item fields like
/// status, title, and last-updated are read fresh at render time via
/// `get_item_by_id`. Callers should therefore avoid invoking this on
/// events that only mutate per-item state (e.g. `ConversationUpdated`);
/// emitting `ConversationListViewModelEvent` is sufficient there.
fn refresh_cached_items(&mut self, ctx: &mut ModelContext<Self>) {
let model = self.conversations_model.as_ref(ctx);
self.cached_conversation_or_task_ids = model
.get_tasks_and_conversations(
&AgentManagementFilters {
owners: OwnerFilter::PersonalOnly,
status: StatusFilter::All,
source: SourceFilter::All,
created_on: CreatedOnFilter::All,
creator: CreatorFilter::All,
artifact: ArtifactFilter::All,
environment: Default::default(),
harness: Default::default(),
},
ctx,
)
// Expired and Unavailable ambient agent sessions can't be opened, so we filter them out.
// Regular conversations have None session_status
.filter(|item| {
item.get_session_status()
.is_none_or(|status| status == SessionStatus::Available)
})
// Only show user-initiated sources (Slack, Linear, Interactive) or tasks that have
// been manually opened from the management page.
.filter(|item| {
let is_user_initiated = item.source().is_some_and(|s| s.is_user_initiated());
let is_manually_opened = match item {
ConversationOrTask::Task(task) => model.is_task_manually_opened(&task.task_id),
ConversationOrTask::Conversation(_) => false,
};
is_user_initiated || is_manually_opened
})
.map(|item| match item {
ConversationOrTask::Task(task) => ConversationOrTaskId::TaskId(task.task_id),
ConversationOrTask::Conversation(conv) => {
ConversationOrTaskId::ConversationId(conv.nav_data.id)
}
})
.collect();
self.apply_search_filter(ctx);
ctx.emit(ConversationListViewModelEvent);
}
pub fn set_search_query(&mut self, query: String, ctx: &mut ModelContext<Self>) {
if query == self.search_query {
return;
}
self.search_query = query;
self.apply_search_filter(ctx);
ctx.emit(ConversationListViewModelEvent);
}
fn apply_search_filter(&mut self, ctx: &mut ModelContext<Self>) {
let search_query = self.search_query.trim().to_lowercase();
let conversations_model = self.conversations_model.as_ref(ctx);
if search_query.is_empty() {
self.filtered_items = self
.cached_conversation_or_task_ids
.iter()
.map(|id| ConversationEntry {
id: *id,
highlight_indices: vec![],
})
.collect();
} else {
let mut matched_items: Vec<(i64, ConversationEntry)> = self
.cached_conversation_or_task_ids
.iter()
.filter_map(|id| {
let item = match id {
ConversationOrTaskId::TaskId(task_id) => {
conversations_model.get_task(task_id)
}
ConversationOrTaskId::ConversationId(conv_id) => {
conversations_model.get_conversation(conv_id)
}
}?;
match_indices_case_insensitive(&item.title(ctx), &search_query).map(|result| {
(
result.score,
ConversationEntry {
id: *id,
highlight_indices: result.matched_indices,
},
)
})
})
.collect();
matched_items.sort_by(|a, b| b.0.cmp(&a.0));
self.filtered_items = matched_items.into_iter().map(|(_, item)| item).collect();
}
}
/// Returns the total number of conversations in the model before any filtering is applied.
pub fn unfiltered_item_count(&self) -> usize {
self.cached_conversation_or_task_ids.len()
}
/// Returns the filtered items with their highlight indices.
pub fn filtered_items(&self) -> &[ConversationEntry] {
&self.filtered_items
}
/// Look up a conversation or task by ID.
pub fn get_item_by_id<'a>(
&self,
id: &ConversationOrTaskId,
ctx: &'a AppContext,
) -> Option<ConversationOrTask<'a>> {
let model = self.conversations_model.as_ref(ctx);
match id {
ConversationOrTaskId::TaskId(task_id) => model.get_task(task_id),
ConversationOrTaskId::ConversationId(conv_id) => model.get_conversation(conv_id),
}
}
pub fn current_ids(&self) -> impl Iterator<Item = &ConversationOrTaskId> {
self.filtered_items.iter().map(|item| &item.id)
}
}
+50
View File
@@ -0,0 +1,50 @@
use warp_cli::RecoveryMechanism;
use warpui::{AppContext, SingletonEntity as _, ViewContext};
use crate::crash_recovery::CrashRecovery;
use super::{Workspace, WorkspaceBannerFields};
pub fn banner_metadata(ctx: &AppContext) -> Option<WorkspaceBannerFields> {
let crash_recovery = CrashRecovery::as_ref(ctx);
let recovery_mechanism = crash_recovery.should_notify_user_about_crash()?;
match recovery_mechanism {
#[cfg(target_os = "linux")]
RecoveryMechanism::X11 => Some(WorkspaceBannerFields {
banner_type: super::WorkspaceBanner::WaylandCrashRecovery,
severity: super::BannerSeverity::Warning,
heading: None,
description: "We detected a crash during application startup, and adjusted your \
settings to use Xwayland for windowing. This can result in blurry text if you \
are using fractional scaling."
.to_owned(),
secondary_button: None,
button: Some(super::WorkspaceBannerButtonDetails {
text: "Learn More".to_owned(),
action: super::WorkspaceAction::DismissWaylandCrashRecoveryBannerAndOpenLink,
variant: super::BannerButtonVariant::Outlined,
icon: None,
more_info_button_action: None,
}),
}),
// We're not showing anything to the user when we recover from a crash
// by switching from preferring integrated to dedicated gpu due to the
// fact that this recovery mechanism is only used when the user has not
// explicitly set their preference.
RecoveryMechanism::DedicatedGpu => None,
// We don't show any information to the user for the disable OpenGL / force Vulkan recovery
// mechanisms. These set of crashes occur before there is a visible window, so any
// information surfaced to the user would be unactionable noise that the user would see on
// every invocation of Warp.
RecoveryMechanism::DisableOpenGL | RecoveryMechanism::ForceVulkan => None,
}
}
#[cfg_attr(all(enable_crash_recovery, not(target_os = "linux")), allow(unused))]
pub fn dismiss_workspace_banner(ctx: &mut ViewContext<Workspace>) {
CrashRecovery::handle(ctx).update(ctx, |crash_recovery, ctx| {
crash_recovery.handle_user_acknowledged_crash(ctx);
});
}
@@ -0,0 +1,472 @@
use crate::ai::{AIRequestUsageModel, AIRequestUsageModelEvent};
use crate::auth::AuthStateProvider;
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::TelemetryEvent;
use asset_macro::bundled_or_fetched_asset;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use thousands::Separable;
use warp_core::send_telemetry_from_ctx;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::{Fill, WarpTheme};
use warp_graphql::billing::{PlanPricing, StripeSubscriptionPlan};
use warpui::elements::{
Align, Border, CacheOption, ChildAnchor, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DropShadow, Flex, FormattedTextElement, HighlightedHyperlink, Image,
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack,
};
use warpui::fonts::Weight;
use warpui::keymap::FixedBinding;
use warpui::platform::Cursor;
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
const BUTTON_DIAMETER: f32 = 20.;
const MODAL_HEIGHT: f32 = 440.;
const LEFT_PANEL_WIDTH: f32 = 360.;
const RIGHT_PANEL_WIDTH: f32 = 360.;
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
FreeTierLimitHitModalAction::Close,
id!("FreeTierLimitHitModal"),
)]);
}
#[derive(Default)]
struct StateHandles {
close_button: MouseStateHandle,
upgrade_button: MouseStateHandle,
}
pub struct FreeTierLimitHitModal {
state_handles: StateHandles,
}
impl FreeTierLimitHitModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
ctx.subscribe_to_model(
&PricingInfoModel::handle(ctx),
|_, _, event, ctx| match event {
PricingInfoModelEvent::PricingInfoUpdated => {
ctx.unsubscribe_to_model(&PricingInfoModel::handle(ctx));
ctx.notify();
}
},
);
ctx.subscribe_to_model(
&AIRequestUsageModel::handle(ctx),
|_, _, event, ctx| match event {
AIRequestUsageModelEvent::RequestUsageUpdated => {
ctx.emit(FreeTierLimitHitModalEvent::MaybeOpen);
}
AIRequestUsageModelEvent::RequestBonusRefunded { .. } => {}
},
);
FreeTierLimitHitModal {
state_handles: Default::default(),
}
}
fn get_upgrade_url(ctx: &ViewContext<Self>) -> String {
let auth_state = AuthStateProvider::handle(ctx).as_ref(ctx).get();
if let Some(team) = UserWorkspaces::handle(ctx).as_ref(ctx).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)
}
}
fn get_build_plan_details(app: &AppContext) -> Option<&PlanPricing> {
let pricing_model = PricingInfoModel::handle(app).as_ref(app);
pricing_model.plan_pricing(&StripeSubscriptionPlan::Build)
}
fn render_checklist_item_dynamic(
text: String,
appearance: &Appearance,
theme: &WarpTheme,
) -> Box<dyn Element> {
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(text),
])]);
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
Icon::CheckCircleBroken
.to_warpui_icon(Fill::Solid(theme.ansi_fg_green()))
.finish(),
)
.with_width(14.)
.with_height(14.)
.finish(),
)
.with_margin_right(4.)
.finish(),
)
.with_child(
FormattedTextElement::new(
formatted_text,
14.,
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_sub(theme, blended_colors::neutral_1(theme)),
HighlightedHyperlink::default(),
)
.finish(),
)
.finish()
}
fn render_left_panel(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::handle(app).as_ref(app);
let theme = appearance.theme();
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Container::new(
FormattedTextElement::from_str(
"Youre out of credits",
appearance.ui_font_family(),
24.,
)
.with_color(blended_colors::text_main(
theme,
blended_colors::neutral_1(theme),
))
.with_weight(Weight::Bold)
.finish(),
)
.with_margin_bottom(12.)
.finish(),
)
.with_child(
Container::new(
FormattedTextElement::from_str(
"To continue using AI, please upgrade your plan.",
appearance.ui_font_family(),
14.,
)
.with_color(blended_colors::text_sub(
theme,
blended_colors::neutral_1(theme),
))
.finish(),
)
.with_margin_bottom(16.)
.finish(),
)
.with_child(
Container::new({
let benefits_text = if let Some(plan) = Self::get_build_plan_details(app) {
let price = plan.monthly_plan_price_per_month_usd_cents / 100;
format!("The Build plan is ${price}/month which includes everything in the free tier plus:")
} else {
"The Build plan includes everything in the free tier plus:".to_string()
};
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(benefits_text),
])]);
FormattedTextElement::new(
formatted_text,
14.,
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_sub(theme, blended_colors::neutral_1(theme)),
HighlightedHyperlink::default(),
)
.finish()
})
.with_margin_bottom(8.)
.finish(),
)
.with_child(
Container::new({
let credits_text = if let Some(plan) = Self::get_build_plan_details(app) {
let limit = plan.request_limit.unwrap_or(1500);
format!("{} Credits per month", limit.separate_with_commas())
} else {
"Extended Credits per month".to_string()
};
Self::render_checklist_item_dynamic(credits_text, appearance, theme)
})
.with_margin_bottom(8.)
.finish(),
)
.with_child(
Container::new(
Self::render_checklist_item_dynamic(
"Access to frontier OpenAI, Anthropic, and Google models".to_string(),
appearance,
theme,
)
)
.with_margin_bottom(8.)
.finish(),
)
.with_child(
Container::new({
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("Access to "),
FormattedTextFragment::hyperlink(
"Reload Credits".to_string(),
"https://docs.warp.dev/support-and-community/plans-and-billing/add-on-credits".to_string(),
),
])]);
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
Icon::CheckCircleBroken
.to_warpui_icon(Fill::Solid(theme.ansi_fg_green()))
.finish(),
)
.with_width(14.)
.with_height(14.)
.finish(),
)
.with_margin_right(4.)
.finish(),
)
.with_child(
FormattedTextElement::new(
formatted_text,
14.,
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_sub(theme, blended_colors::neutral_1(theme)),
HighlightedHyperlink::default(),
)
.register_default_click_handlers(|url, ctx, _| {
ctx.dispatch_typed_action(FreeTierLimitHitModalAction::OpenUrl(url.url.clone()));
})
.finish(),
)
.finish()
})
.with_margin_bottom(8.)
.finish(),
)
.with_child(
Container::new({
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::hyperlink(
"Extended cloud agents access".to_string(),
"https://www.warp.dev/oz".to_string(),
),
])]);
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
Icon::CheckCircleBroken
.to_warpui_icon(Fill::Solid(theme.ansi_fg_green()))
.finish(),
)
.with_width(14.)
.with_height(14.)
.finish(),
)
.with_margin_right(4.)
.finish(),
)
.with_child(
FormattedTextElement::new(
formatted_text,
14.,
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_sub(theme, blended_colors::neutral_1(theme)),
HighlightedHyperlink::default(),
)
.register_default_click_handlers(|url, ctx, _| {
ctx.dispatch_typed_action(FreeTierLimitHitModalAction::OpenUrl(url.url.clone()));
})
.finish(),
)
.finish()
})
.finish(),
)
.finish(),
)
.with_child(
Align::new(
appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.state_handles.upgrade_button.clone(),
)
.with_style(UiComponentStyles {
font_size: Some(14.),
height: Some(32.),
width: Some(296.),
..Default::default()
})
.with_centered_text_label("Upgrade plan".to_string())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(FreeTierLimitHitModalAction::OpenUpgrade)
})
.finish(),
)
.bottom_left()
.finish(),
)
.finish(),
)
.with_background_color(blended_colors::neutral_1(theme))
.with_corner_radius(CornerRadius::with_left(Radius::Pixels(10.)))
.with_uniform_padding(32.)
.finish()
}
fn render_right_panel(&self) -> Box<dyn Element> {
ConstrainedBox::new(
Image::new(
bundled_or_fetched_asset!("png/free_tier_to_build.png"),
CacheOption::BySize,
)
.with_corner_radius(CornerRadius::with_right(Radius::Pixels(10.)))
.finish(),
)
.with_width(RIGHT_PANEL_WIDTH)
.with_height(MODAL_HEIGHT)
.finish()
}
}
impl Entity for FreeTierLimitHitModal {
type Event = FreeTierLimitHitModalEvent;
}
impl View for FreeTierLimitHitModal {
fn ui_name() -> &'static str {
"FreeTierLimitHitModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::handle(app).as_ref(app);
let theme = appearance.theme();
let close_button = appearance
.ui_builder()
.close_button(BUTTON_DIAMETER, self.state_handles.close_button.clone())
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(FreeTierLimitHitModalAction::Close))
.finish();
let mut modal = Stack::new();
modal.add_child(
Container::new(
ConstrainedBox::new(
Flex::row()
.with_child(
ConstrainedBox::new(self.render_left_panel(app))
.with_width(LEFT_PANEL_WIDTH)
.with_height(MODAL_HEIGHT)
.finish(),
)
.with_child(self.render_right_panel())
.finish(),
)
.with_width(LEFT_PANEL_WIDTH + RIGHT_PANEL_WIDTH)
.with_height(MODAL_HEIGHT)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.)))
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_drop_shadow(DropShadow::default())
.finish(),
);
modal.add_positioned_child(
close_button,
OffsetPositioning::offset_from_parent(
vec2f(-8., 8.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
let mut stack = Stack::new();
stack.add_positioned_child(
modal.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
Container::new(Align::new(stack.finish()).finish())
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
.finish()
}
}
impl TypedActionView for FreeTierLimitHitModal {
type Action = FreeTierLimitHitModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
FreeTierLimitHitModalAction::Close => {
ctx.emit(FreeTierLimitHitModalEvent::Close);
send_telemetry_from_ctx!(TelemetryEvent::FreeTierLimitHitInterstitialClosed, ctx);
}
FreeTierLimitHitModalAction::OpenUpgrade => {
let upgrade_url = Self::get_upgrade_url(ctx);
ctx.open_url(&upgrade_url);
ctx.emit(FreeTierLimitHitModalEvent::Close);
send_telemetry_from_ctx!(
TelemetryEvent::FreeTierLimitHitInterstitialUpgradeButtonClicked,
ctx
);
}
FreeTierLimitHitModalAction::OpenUrl(url) => {
ctx.open_url(url);
}
}
}
}
#[derive(Copy, Clone, Debug)]
pub enum FreeTierLimitHitModalEvent {
MaybeOpen,
Close,
}
#[derive(Clone, Debug)]
pub enum FreeTierLimitHitModalAction {
Close,
OpenUpgrade,
OpenUrl(String),
}
@@ -0,0 +1,9 @@
pub struct SearchConfig {
pub use_regex: bool,
pub use_case_sensitivity: bool,
}
#[cfg_attr(not(target_family = "wasm"), path = "model.rs")]
#[cfg_attr(target_family = "wasm", path = "model_wasm.rs")]
pub mod model;
pub mod view;
@@ -0,0 +1,255 @@
use crate::workspace::view::global_search::view::GlobalSearchEvent;
use crate::workspace::view::global_search::SearchConfig;
use anyhow::Result;
use futures::StreamExt as _;
use instant::Instant;
use num_traits::SaturatingSub;
use regex::escape;
use std::path::PathBuf;
use string_offset::ByteOffset;
use warp_ripgrep::search::{Match as RipgrepMatch, Submatch};
use warpui::r#async::SpawnedFutureHandle;
use warpui::{Entity, ModelContext, ModelSpawner};
const START_BATCH_AFTER_COUNT: usize = 50;
const MAX_BATCH_SIZE: usize = 512;
const MAX_BATCH_AGE_MS: u64 = 4000;
pub struct GlobalSearch {
search_handle: Option<SpawnedFutureHandle>,
// track the search ID so that we only show results for the current search
next_search_id: u32,
}
impl Entity for GlobalSearch {
type Event = GlobalSearchEvent;
}
async fn flush_batch(
spawner: &ModelSpawner<GlobalSearch>,
search_id: u32,
batch: &mut Vec<RipgrepMatch>,
) {
if batch.is_empty() {
return;
}
let items = std::mem::take(batch);
let _ = spawner
.spawn(move |_me, ctx| {
ctx.emit(GlobalSearchEvent::ProgressBatch { search_id, items });
})
.await;
}
impl GlobalSearch {
pub fn new() -> Self {
GlobalSearch {
search_handle: None,
next_search_id: 1,
}
}
pub fn abort_search(&mut self) {
if let Some(handle) = self.search_handle.take() {
handle.abort();
}
}
pub fn run_search(
&mut self,
pattern: String,
roots: Vec<PathBuf>,
search_config: SearchConfig,
ctx: &mut ModelContext<Self>,
) {
if let Some(handle) = self.search_handle.take() {
log::info!("GlobalSearch: aborting previous search");
handle.abort();
}
let search_id = self.next_search_id;
self.next_search_id += 1;
ctx.emit(GlobalSearchEvent::Started { search_id });
let spawner = ctx.spawner();
let effective_pattern = if search_config.use_regex {
pattern
} else {
escape(&pattern)
};
let ignore_case = !search_config.use_case_sensitivity;
let multiline = effective_pattern.contains('\n');
let handle = ctx.spawn(
async move {
Self::run_warp_ripgrep_cli(
search_id,
effective_pattern,
roots,
ignore_case,
multiline,
spawner,
)
.await
},
move |_, result, ctx| match result {
Ok(total_match_count) => {
ctx.emit(GlobalSearchEvent::Completed {
search_id,
total_match_count,
});
}
Err(err) => {
log::error!("GlobalSearch: warp_ripgrep CLI search failed or aborted: {err}");
ctx.emit(GlobalSearchEvent::Failed {
search_id,
error: "Global search failed.".to_string(),
});
}
},
);
self.search_handle = Some(handle);
}
async fn run_warp_ripgrep_cli(
search_id: u32,
pattern: String,
roots: Vec<PathBuf>,
ignore_case: bool,
multiline: bool,
spawner: ModelSpawner<GlobalSearch>,
) -> Result<usize> {
let roots_display: Vec<_> = roots.iter().map(|r| r.display().to_string()).collect();
log::info!(
"GlobalSearch: starting warp_ripgrep CLI search with pattern={pattern}, roots={:?}",
roots_display
);
let stream =
warp_ripgrep::search::search_streaming(&[pattern], &roots, ignore_case, multiline)?;
futures::pin_mut!(stream);
let mut total_match_count: usize = 0;
let mut num_unbatched_emitted: usize = 0;
let mut batch: Vec<RipgrepMatch> = Vec::new();
let mut last_batch_flush_at = Instant::now();
while let Some(raw_match) = stream.next().await {
// Expand each submatch into its own result row (matching
// the old per-submatch behavior). Each row gets the line
// text trimmed up to that particular submatch.
for per_submatch in Self::expand_submatches(raw_match) {
total_match_count += 1;
if num_unbatched_emitted < START_BATCH_AFTER_COUNT {
num_unbatched_emitted += 1;
let _ = spawner
.spawn(move |_me, ctx| {
ctx.emit(GlobalSearchEvent::Progress {
search_id,
result: per_submatch,
});
})
.await;
} else {
batch.push(per_submatch);
let too_big = batch.len() >= MAX_BATCH_SIZE;
let too_old =
last_batch_flush_at.elapsed().as_millis() >= MAX_BATCH_AGE_MS as u128;
if too_big || too_old {
flush_batch(&spawner, search_id, &mut batch).await;
last_batch_flush_at = Instant::now();
}
}
}
}
if !batch.is_empty() {
flush_batch(&spawner, search_id, &mut batch).await;
}
Ok(total_match_count)
}
/// Expand a single ripgrep match (which may contain multiple submatches
/// on the same line) into one result per submatch. Each result gets the
/// line text trimmed of leading whitespace up to that submatch.
fn expand_submatches(m: RipgrepMatch) -> Vec<RipgrepMatch> {
if m.submatches.len() <= 1 {
return vec![Self::trim_leading_whitespace_for_submatch(
&m.line_text,
m.file_path,
m.line_number,
m.submatches.into_iter().next(),
)];
}
m.submatches
.into_iter()
.map(|sub| {
Self::trim_leading_whitespace_for_submatch(
&m.line_text,
m.file_path.clone(),
m.line_number,
Some(sub),
)
})
.collect()
}
/// Trim leading whitespace from a line up to the given submatch,
/// adjusting the submatch offset accordingly.
fn trim_leading_whitespace_for_submatch(
original_line: &str,
file_path: PathBuf,
line_number: u32,
submatch: Option<Submatch>,
) -> RipgrepMatch {
let submatch_start = submatch
.as_ref()
.map(|s| s.byte_start)
.unwrap_or(ByteOffset::zero());
let mut leading_trimmed_bytes = ByteOffset::zero();
for (byte_index, ch) in original_line.char_indices() {
if byte_index >= submatch_start.as_usize() {
break;
}
if !ch.is_ascii_whitespace() {
break;
}
leading_trimmed_bytes += ch.len_utf8();
}
let trimmed_line = original_line[leading_trimmed_bytes.as_usize()..].to_string();
let submatches = if let Some(sub) = submatch {
vec![Submatch {
byte_start: sub.byte_start.saturating_sub(&leading_trimmed_bytes),
byte_end: sub.byte_end.saturating_sub(&leading_trimmed_bytes),
}]
} else {
Vec::new()
};
RipgrepMatch {
file_path,
line_number,
line_text: trimmed_line,
submatches,
}
}
}
impl Default for GlobalSearch {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,34 @@
use std::path::PathBuf;
use crate::workspace::view::global_search::view::GlobalSearchEvent;
use crate::workspace::view::global_search::SearchConfig;
use warpui::{Entity, ModelContext};
pub struct GlobalSearch {}
impl Entity for GlobalSearch {
type Event = GlobalSearchEvent;
}
impl GlobalSearch {
pub fn new() -> Self {
GlobalSearch {}
}
pub fn abort_search(&mut self) {}
pub fn run_search(
&mut self,
_pattern: String,
_root: Vec<PathBuf>,
_search_config: SearchConfig,
_ctx: &mut ModelContext<Self>,
) {
}
}
impl Default for GlobalSearch {
fn default() -> Self {
Self::new()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
use super::Slide;
use crate::server::telemetry::TelemetryEvent;
use std::rc::Rc;
use warpui::ViewContext;
/// A callback function for custom CTA button actions.
type CustomCallback<S> = Rc<dyn Fn(&mut ViewContext<super::LaunchModal<S>>)>;
#[derive(Clone)]
pub struct CTAButton<S: Slide> {
pub label: String,
pub action: CTAButtonAction<S>,
#[allow(dead_code)]
pub telemetry_event: Option<TelemetryEvent>,
}
impl<S: Slide> CTAButton<S> {
// Constructor methods
pub fn next_slide(next: S, label: impl Into<String>) -> Self {
Self {
label: label.into(),
action: CTAButtonAction::NextSlide(next),
telemetry_event: None,
}
}
pub fn close(label: impl Into<String>) -> Self {
Self {
label: label.into(),
action: CTAButtonAction::Close,
telemetry_event: None,
}
}
#[allow(dead_code)]
pub fn open_url(label: impl Into<String>, url: impl Into<String>) -> Self {
Self {
label: label.into(),
action: CTAButtonAction::OpenUrl(url.into()),
telemetry_event: None,
}
}
pub fn custom<F>(label: impl Into<String>, callback: F) -> Self
where
F: Fn(&mut ViewContext<super::LaunchModal<S>>) + 'static,
{
Self {
label: label.into(),
action: CTAButtonAction::Custom(Rc::new(callback)),
telemetry_event: None,
}
}
#[allow(dead_code)]
pub fn with_telemetry(mut self, event: TelemetryEvent) -> Self {
self.telemetry_event = Some(event);
self
}
}
pub enum CTAButtonAction<S: Slide> {
NextSlide(S),
Close,
#[allow(dead_code)]
OpenUrl(String),
Custom(CustomCallback<S>),
}
impl<S: Slide> Clone for CTAButtonAction<S> {
fn clone(&self) -> Self {
match self {
CTAButtonAction::NextSlide(s) => CTAButtonAction::NextSlide(*s),
CTAButtonAction::Close => CTAButtonAction::Close,
CTAButtonAction::OpenUrl(url) => CTAButtonAction::OpenUrl(url.clone()),
CTAButtonAction::Custom(f) => CTAButtonAction::Custom(f.clone()),
}
}
}
+746
View File
@@ -0,0 +1,746 @@
// Specific slide implementations
pub mod cta_button;
pub mod oz_launch;
// Re-export slide types for convenience
pub use oz_launch::OzLaunchSlide;
use crate::settings::PrivacySettings;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, PrimaryTheme, SecondaryTheme};
use crate::workspace::view::launch_modal::cta_button::{CTAButton, CTAButtonAction};
use markdown_parser::{parse_markdown, FormattedText, FormattedTextLine};
use pathfinder_color::ColorU;
use std::collections::HashMap;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::Fill;
use warpui::assets::asset_cache::AssetSource;
use warpui::elements::{
Align, Border, CacheOption, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DropShadow, Empty, Expanded, Flex, FormattedTextElement,
HighlightedHyperlink, Hoverable, HyperlinkLens, Image, MainAxisAlignment, MainAxisSize,
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Stack,
};
use warpui::fonts::Weight;
use warpui::keymap::FixedBinding;
use warpui::platform::Cursor;
use warpui::presenter::ChildView;
use warpui::ui_components::components::UiComponent;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
pub fn init<S: Slide>(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([
FixedBinding::new("escape", LaunchModalAction::<S>::Close, id!("LaunchModal")),
FixedBinding::new(
"enter",
LaunchModalAction::<S>::NextSlide,
id!("LaunchModal"),
),
FixedBinding::new(
"left",
LaunchModalAction::<S>::PrevSlide,
id!("LaunchModal"),
),
FixedBinding::new(
"right",
LaunchModalAction::<S>::NextSlide,
id!("LaunchModal"),
),
FixedBinding::new("up", LaunchModalAction::<S>::PrevSlide, id!("LaunchModal")),
FixedBinding::new(
"down",
LaunchModalAction::<S>::NextSlide,
id!("LaunchModal"),
),
]);
}
/// Configuration for an optional checkbox displayed in the modal's control panel.
pub struct CheckboxConfig {
pub label: &'static str,
pub description: &'static str,
}
pub trait Slide:
'static + Send + Sync + std::fmt::Debug + PartialEq + Eq + std::hash::Hash + Copy + Clone
where
Self: Sized,
{
fn modal_title(&self) -> String;
fn modal_subtext_paragraphs(&self) -> Vec<FormattedTextLine>;
fn first() -> Self;
fn next(&self) -> Option<Self>;
fn prev(&self) -> Option<Self>;
fn display_text(&self) -> Option<&'static str>;
fn short_label(&self) -> &'static str;
fn title(&self) -> &'static str;
fn title_icon(&self) -> Option<Icon>;
fn content(&self) -> &'static str;
fn image(&self) -> AssetSource;
fn all() -> Vec<Self>;
fn cta_button(&self) -> CTAButton<Self>;
/// Returns an optional secondary CTA button for the modal.
/// When Some, a secondary button is rendered alongside the primary CTA.
fn secondary_cta_button(&self) -> Option<CTAButton<Self>> {
None
}
/// Returns an optional checkbox configuration for the modal.
/// When Some, a checkbox is rendered at the bottom of the control panel.
fn checkbox_config(&self) -> Option<CheckboxConfig> {
None
}
/// Returns whether the checkbox should be shown.
/// This is checked in addition to checkbox_config() returning Some.
fn should_show_checkbox(&self, _app: &AppContext) -> bool {
false
}
/// Called when the modal is closed via the X button or esc or close CTA.
/// Not called if closed via another CTA.
fn on_close(&self, _ctx: &mut ViewContext<LaunchModal<Self>>) {}
}
pub struct StateHandles<S: Slide> {
pub close_button: MouseStateHandle,
pub slides: HashMap<S, SlideStateHandles>,
pub checkbox: MouseStateHandle,
}
#[derive(Default)]
pub struct SlideStateHandles {
mouse: MouseStateHandle,
content_hyperlink: HighlightedHyperlink,
}
impl<S: Slide> Default for StateHandles<S> {
fn default() -> Self {
let mut slide_handles = HashMap::new();
for slide in S::all() {
slide_handles.insert(slide, SlideStateHandles::default());
}
StateHandles {
close_button: Default::default(),
slides: slide_handles,
checkbox: Default::default(),
}
}
}
pub struct LaunchModal<S: Slide> {
slide: S,
next_button: ViewHandle<ActionButton>,
secondary_button: ViewHandle<ActionButton>,
state_handles: StateHandles<S>,
}
impl<S: Slide> LaunchModal<S> {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let next_button = ctx.add_view(|_| ActionButton::new("", PrimaryTheme));
let secondary_button = ctx.add_view(|_| ActionButton::new("", SecondaryTheme));
let mut me = LaunchModal {
slide: S::first(),
next_button,
secondary_button,
state_handles: Default::default(),
};
me.update_buttons_based_on_slide(ctx);
me
}
fn update_buttons_based_on_slide(&mut self, ctx: &mut ViewContext<Self>) {
self.next_button
.update(ctx, |next_button, ctx| match self.slide.cta_button() {
CTAButton {
label,
action: CTAButtonAction::NextSlide(next),
..
} => {
next_button.set_label(label, ctx);
next_button.set_on_click(
move |ctx| ctx.dispatch_typed_action(LaunchModalAction::SelectSlide(next)),
ctx,
);
}
CTAButton { label, .. } => {
next_button.set_label(label, ctx);
next_button.set_on_click(
move |ctx| ctx.dispatch_typed_action(LaunchModalAction::<S>::Finish),
ctx,
);
}
});
// Update secondary button if present.
if let Some(secondary_cta) = self.slide.secondary_cta_button() {
self.secondary_button
.update(ctx, |secondary_button, ctx| match secondary_cta {
CTAButton {
label,
action: CTAButtonAction::NextSlide(next),
..
} => {
secondary_button.set_label(label, ctx);
secondary_button.set_on_click(
move |ctx| {
ctx.dispatch_typed_action(LaunchModalAction::SelectSlide(next))
},
ctx,
);
}
CTAButton { label, .. } => {
secondary_button.set_label(label, ctx);
secondary_button.set_on_click(
move |ctx| {
ctx.dispatch_typed_action(LaunchModalAction::<S>::FinishSecondary)
},
ctx,
);
}
});
}
ctx.notify();
}
fn render_checkbox(&self, app: &AppContext) -> Option<Box<dyn Element>> {
if !self.slide.should_show_checkbox(app) {
return None;
}
let checkbox_config = self.slide.checkbox_config()?;
let appearance = Appearance::handle(app).as_ref(app);
let theme = appearance.theme();
let is_checked = PrivacySettings::handle(app)
.as_ref(app)
.is_cloud_conversation_storage_enabled;
let checkbox = appearance
.ui_builder()
.checkbox(self.state_handles.checkbox.clone(), Some(10.5))
.check(is_checked)
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(LaunchModalAction::<S>::ToggleCheckbox))
.finish();
let label =
FormattedTextElement::from_str(checkbox_config.label, appearance.ui_font_family(), 12.)
.with_color(blended_colors::text_sub(
theme,
blended_colors::neutral_1(theme),
))
.finish();
let description = FormattedTextElement::from_str(
checkbox_config.description,
appearance.ui_font_family(),
12.,
)
.with_color(blended_colors::text_disabled(
theme,
blended_colors::neutral_1(theme),
))
.finish();
Some(
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(checkbox)
.with_child(Container::new(label).with_margin_left(4.).finish())
.finish(),
)
.with_child(Container::new(description).with_margin_top(4.).finish())
.finish(),
)
.with_margin_top(24.)
.finish(),
)
}
fn render_slide_controls(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
// Only show slide controls if there are multiple slides or if slides have display text
let slides_with_display_text: Vec<_> = S::all()
.into_iter()
.filter_map(|slide| slide.display_text().map(|text| (slide, text)))
.collect();
if slides_with_display_text.len() <= 1 {
// For single-slide modals or slides without display text, return empty container
return Container::new(Flex::column().finish()).finish();
}
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (i, (slide, display_text)) in slides_with_display_text.into_iter().enumerate() {
let mut label =
FormattedTextElement::from_str(display_text, appearance.ui_font_family(), 14.)
.with_color(blended_colors::text_main(
theme,
blended_colors::neutral_1(theme),
));
if slide == self.slide {
label = label.with_weight(Weight::Bold);
}
let mut container = Container::new(Align::new(label.finish()).left().finish())
.with_horizontal_padding(12.)
.with_vertical_padding(8.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background(blended_colors::neutral_1(theme));
if slide == self.slide {
container = container.with_background(blended_colors::fg_overlay_3(theme))
}
if i < S::all().len() {
container = container.with_margin_bottom(8.)
}
column.add_child(if slide == self.slide {
container.finish()
} else {
Hoverable::new(
self.state_handles.slides[&slide].mouse.clone(),
move |state| {
if state.is_hovered() {
container
.with_background(blended_colors::fg_overlay_3(theme))
.finish()
} else {
container.finish()
}
},
)
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(LaunchModalAction::SelectSlide(slide))
})
.finish()
});
}
column.finish()
}
fn render_current_slide(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let text_container = Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(
Shrinkable::new(
1.,
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Container::new({
let text = FormattedTextElement::from_str(
self.slide.title(),
appearance.ui_font_family(),
16.,
)
.with_color(blended_colors::text_main(
theme,
blended_colors::neutral_2(theme),
))
.with_weight(Weight::Bold)
.finish();
if let Some(icon) = self.slide.title_icon() {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(text)
.with_child(
Container::new(
ConstrainedBox::new(
icon.to_warpui_icon(Fill::Solid(
blended_colors::text_main(
theme,
blended_colors::neutral_2(theme),
),
))
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_margin_left(6.)
// Agent icon's bounding box makes the icon look too
// high relative to the text.
.with_margin_top(-2.)
.finish(),
)
.finish()
} else {
text
}
})
.with_margin_bottom(8.)
.finish(),
)
.with_child(
Container::new(
Shrinkable::new(
1.,
FormattedTextElement::new(
parse_markdown(self.slide.content()).unwrap(),
14.,
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_sub(
theme,
blended_colors::neutral_4(theme),
),
self.state_handles.slides[&self.slide]
.content_hyperlink
.clone(),
)
.with_hyperlink_font_color(theme.accent().into_solid())
.register_default_click_handlers_with_action_support(
|hyperlink_lens, _event, ctx| {
if let HyperlinkLens::Url(url) = hyperlink_lens {
ctx.open_url(url);
}
},
)
.finish(),
)
.finish(),
)
.with_margin_bottom(8.)
.finish(),
)
.finish(),
)
.finish(),
)
.with_child(
Align::new(
Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_children(self.slide.secondary_cta_button().map(|_| {
Container::new(ChildView::new(&self.secondary_button).finish())
.with_margin_right(8.)
.finish()
}))
.with_child(ChildView::new(&self.next_button).finish())
.finish(),
)
.bottom_right()
.finish(),
)
.finish();
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Clipped::new(
ConstrainedBox::new(
Image::new(self.slide.image(), CacheOption::Original)
.with_corner_radius(CornerRadius::with_top_right(Radius::Pixels(10.)))
.cover()
.finish(),
)
.with_max_width(MAX_SLIDE_WIDTH)
.with_min_height(100.)
.with_max_height(MAX_IMAGE_HEIGHT)
.finish(),
)
.finish(),
)
.with_child(
Expanded::new(
1.,
Container::new(text_container)
.with_uniform_padding(24.)
.with_background(blended_colors::neutral_2(theme))
.with_border(
Border::left(1.).with_border_color(blended_colors::neutral_4(theme)),
)
.with_corner_radius(CornerRadius::with_bottom_right(Radius::Pixels(10.)))
.finish(),
)
.finish(),
)
.finish()
}
fn handle_cta_button_action(&self, ctx: &mut ViewContext<Self>) {
let cta_button = self.slide.cta_button();
match cta_button.action {
CTAButtonAction::NextSlide(_) => {}
CTAButtonAction::Close => {
self.slide.on_close(ctx);
ctx.emit(LaunchModalEvent::Close);
}
CTAButtonAction::OpenUrl(url) => {
ctx.open_url(&url);
ctx.emit(LaunchModalEvent::Close);
}
CTAButtonAction::Custom(callback) => {
callback(ctx);
}
}
}
fn handle_secondary_cta_button_action(&self, ctx: &mut ViewContext<Self>) {
let Some(cta_button) = self.slide.secondary_cta_button() else {
return;
};
match cta_button.action {
CTAButtonAction::NextSlide(_) => {}
CTAButtonAction::Close => {
self.slide.on_close(ctx);
ctx.emit(LaunchModalEvent::Close);
}
CTAButtonAction::OpenUrl(url) => {
ctx.open_url(&url);
ctx.emit(LaunchModalEvent::Close);
}
CTAButtonAction::Custom(callback) => {
callback(ctx);
}
}
}
}
impl<S: Slide> Entity for LaunchModal<S> {
type Event = LaunchModalEvent;
}
// Modal dimension constants.
const MAX_MODAL_WIDTH: f32 = 876.;
const MIN_MODAL_HEIGHT: f32 = 300.;
const MAX_MODAL_HEIGHT: f32 = 540.;
const MAX_CONTROL_PANEL_WIDTH: f32 = 333.;
const MIN_CONTROL_PANEL_WIDTH: f32 = 220.;
const MAX_SLIDE_WIDTH: f32 = 543.;
const MAX_IMAGE_HEIGHT: f32 = 355.;
/// Minimum width below which the modal is hidden.
const MIN_MODAL_WIDTH: f32 = 600.;
impl<S: Slide> View for LaunchModal<S> {
fn ui_name() -> &'static str {
"LaunchModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
const BUTTON_DIAMETER: f32 = 20.;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let control_panel = Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Container::new(
FormattedTextElement::from_str(
self.slide.modal_title(),
appearance.ui_font_family(),
24.,
)
.with_color(blended_colors::text_main(
theme,
blended_colors::neutral_1(theme),
))
.with_weight(Weight::Bold)
.finish(),
)
.with_margin_bottom(12.)
.finish(),
)
.with_children(
self.slide
.modal_subtext_paragraphs()
.iter()
.enumerate()
.map(|(index, line)| {
let is_last = index == self.slide.modal_subtext_paragraphs().len() - 1;
let text_element = FormattedTextElement::new(
FormattedText::new([line.clone()]),
14.,
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_main(theme, blended_colors::neutral_1(theme)),
Default::default(), // no hyperlink highlighting needed
)
.disable_mouse_interaction()
.finish();
Container::new(text_element)
.with_margin_bottom(if is_last { 40. } else { 8. })
.finish()
}),
)
.with_child(Expanded::new(1., self.render_slide_controls(app)).finish())
.with_children(self.render_checkbox(app))
.finish(),
)
.with_background_color(blended_colors::neutral_1(theme))
.with_corner_radius(CornerRadius::with_left(Radius::Pixels(10.)))
.with_uniform_padding(24.)
.finish();
let close_button = appearance
.ui_builder()
.close_button(BUTTON_DIAMETER, self.state_handles.close_button.clone())
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(LaunchModalAction::<S>::Close))
.finish();
let mut modal = Stack::new();
modal.add_child(
Container::new(
ConstrainedBox::new(
Flex::row()
.with_child(
Shrinkable::new(
MAX_CONTROL_PANEL_WIDTH,
ConstrainedBox::new(control_panel)
.with_min_width(MIN_CONTROL_PANEL_WIDTH)
.with_max_width(MAX_CONTROL_PANEL_WIDTH)
.with_height(MAX_MODAL_HEIGHT)
.finish(),
)
.finish(),
)
.with_child(
Shrinkable::new(
MAX_SLIDE_WIDTH,
ConstrainedBox::new(self.render_current_slide(app))
.with_max_width(MAX_SLIDE_WIDTH)
.with_height(MAX_MODAL_HEIGHT)
.finish(),
)
.finish(),
)
.finish(),
)
.with_max_width(MAX_MODAL_WIDTH)
.with_min_height(MIN_MODAL_HEIGHT)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.)))
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_drop_shadow(DropShadow::default())
.finish(),
);
modal.add_positioned_child(
close_button,
OffsetPositioning::offset_from_parent(
pathfinder_geometry::vector::vec2f(-8., 8.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
// Stack needed so that modal can get bounds information,
// specifically to ensure no overlap with the window's traffic lights.
let mut stack = Stack::new();
stack.add_positioned_child(
modal.finish(),
OffsetPositioning::offset_from_parent(
pathfinder_geometry::vector::vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
// Hide the modal if the window is too narrow to display it properly.
SizeConstraintSwitch::new(
Container::new(Align::new(stack.finish()).finish())
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
.finish(),
[(
SizeConstraintCondition::WidthLessThan(MIN_MODAL_WIDTH),
Empty::new().finish(),
)],
)
.finish()
}
}
impl<S: Slide> TypedActionView for LaunchModal<S> {
type Action = LaunchModalAction<S>;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
LaunchModalAction::SelectSlide(slide) => {
self.slide = *slide;
self.update_buttons_based_on_slide(ctx);
ctx.notify();
}
LaunchModalAction::NextSlide => {
if let Some(next_slide) = self.slide.next() {
self.slide = next_slide;
self.update_buttons_based_on_slide(ctx);
ctx.notify();
} else {
// If we're on the last slide, trigger the CTA button action.
self.handle_cta_button_action(ctx);
}
}
LaunchModalAction::PrevSlide => {
if let Some(prev_slide) = self.slide.prev() {
self.slide = prev_slide;
self.update_buttons_based_on_slide(ctx);
ctx.notify();
}
// If we're on the first slide, do nothing.
}
LaunchModalAction::Close => {
self.slide.on_close(ctx);
ctx.emit(LaunchModalEvent::Close);
}
LaunchModalAction::Finish => {
self.handle_cta_button_action(ctx);
}
LaunchModalAction::FinishSecondary => {
self.handle_secondary_cta_button_action(ctx);
}
LaunchModalAction::ToggleCheckbox => {
ctx.emit(LaunchModalEvent::ToggleCheckbox);
}
}
}
}
#[derive(Copy, Clone, Debug)]
pub enum LaunchModalEvent {
Close,
ToggleCheckbox,
}
#[derive(Copy, Clone, Debug)]
pub enum LaunchModalAction<S: Slide> {
SelectSlide(S),
NextSlide,
PrevSlide,
Close,
Finish,
FinishSecondary,
ToggleCheckbox,
}
@@ -0,0 +1,202 @@
use super::{CTAButton, CheckboxConfig, LaunchModalEvent, Slide};
use crate::ai::ambient_agents::telemetry::{CloudAgentTelemetryEvent, CloudModeEntryPoint};
use crate::terminal::view::OnboardingIntention;
use crate::ui_components::icons::Icon;
use crate::workspace::action::WorkspaceAction;
use crate::workspace::view::OnboardingTutorial;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::{AdminEnablementSetting, UgcCollectionEnablementSetting};
use asset_macro::bundled_or_fetched_asset;
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
use warp_core::send_telemetry_from_ctx;
use warpui::assets::asset_cache::AssetSource;
use warpui::{AppContext, SingletonEntity};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OzLaunchSlide {
CloudAgents,
AgentAutomations,
AgentManagement,
LaunchCredits,
}
impl Slide for OzLaunchSlide {
fn modal_title(&self) -> String {
"Introducing Oz".to_string()
}
fn modal_subtext_paragraphs(&self) -> Vec<FormattedTextLine> {
vec![FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(
"Infinitely scalable coding agent — run in local sessions or in the cloud.",
),
])]
}
fn first() -> Self {
OzLaunchSlide::CloudAgents
}
fn next(&self) -> Option<Self> {
match self {
OzLaunchSlide::CloudAgents => Some(OzLaunchSlide::AgentAutomations),
OzLaunchSlide::AgentAutomations => Some(OzLaunchSlide::AgentManagement),
OzLaunchSlide::AgentManagement => Some(OzLaunchSlide::LaunchCredits),
OzLaunchSlide::LaunchCredits => None,
}
}
fn prev(&self) -> Option<Self> {
match self {
OzLaunchSlide::CloudAgents => None,
OzLaunchSlide::AgentAutomations => Some(OzLaunchSlide::CloudAgents),
OzLaunchSlide::AgentManagement => Some(OzLaunchSlide::AgentAutomations),
OzLaunchSlide::LaunchCredits => Some(OzLaunchSlide::AgentManagement),
}
}
fn display_text(&self) -> Option<&'static str> {
Some(match self {
OzLaunchSlide::CloudAgents => "Cloud agents",
OzLaunchSlide::AgentAutomations => "Agent automations",
OzLaunchSlide::AgentManagement => "Agent management",
OzLaunchSlide::LaunchCredits => "A little gift",
})
}
fn short_label(&self) -> &'static str {
match self {
OzLaunchSlide::CloudAgents => "Cloud agents",
OzLaunchSlide::AgentAutomations => "Agent automations",
OzLaunchSlide::AgentManagement => "Agent management",
OzLaunchSlide::LaunchCredits => "Launch credits",
}
}
fn title(&self) -> &'static str {
match self {
OzLaunchSlide::CloudAgents => "Break out of your laptop with cloud agents",
OzLaunchSlide::AgentAutomations => {
"Orchestrate agents, turning Skills into automations"
}
OzLaunchSlide::AgentManagement => "Track local and cloud agents seamlessly",
OzLaunchSlide::LaunchCredits => {
"1,000 free cloud agent credits when you upgrade to Warp Build"
}
}
}
fn title_icon(&self) -> Option<Icon> {
None
}
fn content(&self) -> &'static str {
match self {
OzLaunchSlide::CloudAgents => {
"Use cloud agents to run many agents in parallel, keep agents working when you close your laptop, or start agents programmatically. Plus, you can check on their work through the web."
}
OzLaunchSlide::AgentAutomations => {
"Oz agents can be defined using the standard Skills format. You can use the built in scheduler to setup agents to run autonomously at set intervals, or use the Oz SDK or API to programmatically start and manage Oz agents."
}
OzLaunchSlide::AgentManagement => {
"View all of your agents across local and cloud sessions in the Warp app or at [oz.warp.dev](https://oz.warp.dev). Join live agent sessions, continue tasks locally, and steer agents with one click."
}
OzLaunchSlide::LaunchCredits => {
"Upgrade to Build this month and receive 1,000 extra credits to try using Oz. Credits are only eligible for Oz runs in Warp-hosted cloud environments."
}
}
}
fn image(&self) -> AssetSource {
// TODO: Replace with new images once provided.
match self {
OzLaunchSlide::CloudAgents => {
bundled_or_fetched_asset!("png/oz_cloud_agents.png")
}
OzLaunchSlide::AgentAutomations => {
bundled_or_fetched_asset!("png/oz_agent_automations.png")
}
OzLaunchSlide::AgentManagement => {
bundled_or_fetched_asset!("png/oz_agent_management.png")
}
OzLaunchSlide::LaunchCredits => {
bundled_or_fetched_asset!("png/oz_launch_credits.png")
}
}
}
fn all() -> Vec<Self> {
vec![
OzLaunchSlide::CloudAgents,
OzLaunchSlide::AgentAutomations,
OzLaunchSlide::AgentManagement,
OzLaunchSlide::LaunchCredits,
]
}
fn cta_button(&self) -> CTAButton<Self> {
match self {
OzLaunchSlide::CloudAgents
| OzLaunchSlide::AgentAutomations
| OzLaunchSlide::AgentManagement => {
let next = self.next().expect("Non-final slides should have a next");
CTAButton::next_slide(next, format!("Next: {}", next.short_label()))
}
OzLaunchSlide::LaunchCredits => CTAButton::custom("Try it out", |ctx| {
send_telemetry_from_ctx!(
CloudAgentTelemetryEvent::EnteredCloudMode {
entry_point: CloudModeEntryPoint::OzLaunchModal,
},
ctx
);
ctx.emit(LaunchModalEvent::Close);
ctx.dispatch_typed_action(&WorkspaceAction::StartAgentOnboardingTutorial(
OnboardingTutorial::NoProject {
intention: OnboardingIntention::AgentDrivenDevelopment,
},
));
ctx.dispatch_typed_action(&WorkspaceAction::AddAmbientAgentTab);
}),
}
}
fn secondary_cta_button(&self) -> Option<CTAButton<Self>> {
match self {
OzLaunchSlide::LaunchCredits => Some(CTAButton::close("Skip for now")),
OzLaunchSlide::CloudAgents
| OzLaunchSlide::AgentAutomations
| OzLaunchSlide::AgentManagement => None,
}
}
fn checkbox_config(&self) -> Option<CheckboxConfig> {
Some(CheckboxConfig {
label: "Sync conversations to cloud",
description: "Agent conversations stored in the cloud can be shared with anyone with one click, and allow conversations to be continued across devices and on logout.",
})
}
fn should_show_checkbox(&self, app: &AppContext) -> bool {
let cloud_storage_setting =
UserWorkspaces::as_ref(app).get_cloud_conversation_storage_enablement_setting();
let ugc_setting = UserWorkspaces::as_ref(app).get_ugc_collection_enablement_setting();
// Show checkbox only when user has control over cloud storage AND UGC is not force-enabled.
matches!(
cloud_storage_setting,
AdminEnablementSetting::RespectUserSetting
) && !matches!(ugc_setting, UgcCollectionEnablementSetting::Enable)
}
fn on_close(&self, ctx: &mut warpui::ViewContext<super::LaunchModal<Self>>) {
ctx.dispatch_typed_action(&WorkspaceAction::StartAgentOnboardingTutorial(
OnboardingTutorial::NoProject {
intention: OnboardingIntention::AgentDrivenDevelopment,
},
));
}
}
pub fn init(app: &mut warpui::AppContext) {
super::init::<OzLaunchSlide>(app);
}
File diff suppressed because it is too large Load Diff
+243
View File
@@ -0,0 +1,243 @@
use crate::pane_group::{NewTerminalOptions, PanesLayout};
use crate::settings::AISettings;
use crate::terminal;
use crate::terminal::view::{
AgentOnboardingVersion, OnboardingIntention, OnboardingVersion, TerminalAction,
};
use crate::workspace::Workspace;
use crate::FeatureFlag;
use onboarding::{ProjectOnboardingSettings, SelectedSettings};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use warpui::{SingletonEntity as _, ViewContext};
/// Configuration for starting the agent onboarding tutorial.
#[derive(Debug, Clone)]
pub enum OnboardingTutorial {
/// Start tutorial without a project context.
NoProject { intention: OnboardingIntention },
/// Start tutorial with a project path, but don't run init.
Project {
path: PathBuf,
intention: OnboardingIntention,
},
/// Start tutorial with a project path and run init flow first.
InitProject {
path: PathBuf,
intention: OnboardingIntention,
},
}
impl OnboardingTutorial {
/// Extracts the onboarding intention from any tutorial variant.
pub(crate) fn intention(&self) -> OnboardingIntention {
match self {
OnboardingTutorial::NoProject { intention }
| OnboardingTutorial::Project { intention, .. }
| OnboardingTutorial::InitProject { intention, .. } => *intention,
}
}
}
impl From<SelectedSettings> for OnboardingTutorial {
fn from(settings: SelectedSettings) -> Self {
match settings {
SelectedSettings::AgentDrivenDevelopment {
project_settings, ..
} => match project_settings {
ProjectOnboardingSettings::Project {
selected_local_folder,
initialize_projects_automatically,
} => {
let path = PathBuf::from(selected_local_folder);
// When AgentView is enabled, /init comes at the end of the tutorial.
if !FeatureFlag::AgentView.is_enabled() && initialize_projects_automatically {
OnboardingTutorial::InitProject {
path,
intention: OnboardingIntention::AgentDrivenDevelopment,
}
} else {
OnboardingTutorial::Project {
path,
intention: OnboardingIntention::AgentDrivenDevelopment,
}
}
}
ProjectOnboardingSettings::NoProject => OnboardingTutorial::NoProject {
intention: OnboardingIntention::AgentDrivenDevelopment,
},
},
SelectedSettings::Terminal { .. } => OnboardingTutorial::NoProject {
intention: OnboardingIntention::Terminal,
},
}
}
}
impl Workspace {
/// Start the agent onboarding tutorial.
///
/// Depending on the variant of `tutorial`, this will either:
/// - `NoProject`: Start the tutorial immediately without any project context
/// - `Project`: Change to the project directory and start the tutorial
/// - `InitProject`: Open the repository, wait for init to complete, then start the tutorial
pub(crate) fn start_agent_onboarding_tutorial(
&mut self,
tutorial: OnboardingTutorial,
ctx: &mut ViewContext<Self>,
) {
match tutorial {
OnboardingTutorial::InitProject {
ref path,
intention,
} => {
// Open the repository - this will create a new terminal and trigger init
let Some(path_str) = path.to_str() else {
log::error!("Failed to convert path to string: {path:?}");
return;
};
self.handle_open_repository(path_str, ctx);
// Subscribe to the terminal view to wait for init completion
if let Some(terminal_view_handle) = self.active_session_view(ctx) {
ctx.subscribe_to_view(
&terminal_view_handle,
move |me, terminal_view, event, ctx| {
if let terminal::Event::OnboardingInitCompleted = event {
// Init flow is complete, now start the tutorial
me.dispatch_agent_onboarding_tutorial(true, intention, ctx);
ctx.unsubscribe_to_view(&terminal_view);
}
},
);
}
}
OnboardingTutorial::Project {
ref path,
intention,
} => {
// Create a new terminal in the project directory
self.add_tab_with_pane_layout(
PanesLayout::SingleTerminal(Box::new(NewTerminalOptions {
initial_directory: Some(path.clone()),
hide_homepage: true,
..Default::default()
})),
Arc::new(HashMap::new()),
None,
ctx,
);
self.dispatch_tutorial_when_bootstrapped(true, intention, ctx);
}
OnboardingTutorial::NoProject { intention } => {
self.dispatch_tutorial_when_bootstrapped(false, intention, ctx);
}
}
}
/// Dispatch the onboarding tutorial after the terminal has finished bootstrapping.
pub(crate) fn dispatch_tutorial_when_bootstrapped(
&mut self,
has_project: bool,
intention: OnboardingIntention,
ctx: &mut ViewContext<Self>,
) {
// With new onboarding, skip the guided tour when AI is not enabled
// (e.g. terminal-intent users or users who disabled AI).
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled()
&& !*AISettings::as_ref(ctx).is_any_ai_enabled
{
return;
}
let Some(terminal_view_handle) = self.active_session_view(ctx) else {
log::warn!("No active terminal view for onboarding tutorial");
return;
};
let is_bootstrapped =
terminal_view_handle.read(ctx, |view, _| view.is_login_shell_bootstrapped());
if is_bootstrapped {
// Terminal is already bootstrapped, dispatch immediately
self.dispatch_agent_onboarding_tutorial(has_project, intention, ctx);
} else {
// Wait for bootstrapping to complete
ctx.subscribe_to_view(
&terminal_view_handle,
move |me, terminal_view, event, ctx| {
if let terminal::Event::SessionBootstrapped = event {
me.dispatch_agent_onboarding_tutorial(has_project, intention, ctx);
ctx.unsubscribe_to_view(&terminal_view);
}
},
);
}
}
/// Dispatch the agent onboarding tutorial flow to the active terminal.
fn dispatch_agent_onboarding_tutorial(
&self,
has_project: bool,
intention: OnboardingIntention,
ctx: &mut ViewContext<Self>,
) {
let version = OnboardingVersion::Agent(if FeatureFlag::AgentView.is_enabled() {
AgentOnboardingVersion::AgentModality {
has_project,
intention,
}
} else {
AgentOnboardingVersion::UniversalInput { has_project }
});
self.dispatch_onboarding(TerminalAction::OnboardingFlow(version), ctx);
}
/// Dispatch the onboarding tutorial after a pending command (e.g. worktree
/// setup) finishes in the active terminal. Subscribes to
/// `Event::PendingCommandCompleted` on the active terminal view.
pub(crate) fn dispatch_tutorial_after_setup_commands(
&mut self,
intention: OnboardingIntention,
ctx: &mut ViewContext<Self>,
) {
let Some(terminal_view_handle) = self.active_session_view(ctx) else {
log::warn!("No active terminal view for post-setup onboarding tutorial");
return;
};
// Suppress deferred agent view entry so setup commands run in
// terminal mode and the tutorial starts in terminal mode.
terminal_view_handle.update(ctx, |view, _| {
view.clear_enter_agent_view_after_pending_commands();
});
let has_pending_command = terminal_view_handle.read(ctx, |view, ctx| {
view.has_pending_command_or_awaiting_completion(ctx)
});
if !has_pending_command {
self.dispatch_tutorial_when_bootstrapped(true, intention, ctx);
return;
}
ctx.subscribe_to_view(
&terminal_view_handle,
move |me, terminal_view, event, ctx| {
if let terminal::Event::PendingCommandCompleted = event {
// Start the onboarding tutorial now that setup is done.
// TODO(roland): We do have a directory in this case so we could consider passing has_project = true
// which has an optional /init flow. But the behavior of /init needs to be revisited:
// 1. Sends /init as a query which differs in behavior from /init slash command
// 2. Sends /init even if not in a git repo - unclear if this should happen (depends on desired behavior from 1)
// 3. With no free AI, /init will not work.
me.dispatch_agent_onboarding_tutorial(false, intention, ctx);
ctx.unsubscribe_to_view(&terminal_view);
}
},
);
}
pub(crate) fn should_show_agent_onboarding(&self, _ctx: &mut ViewContext<Self>) -> bool {
FeatureFlag::AgentOnboarding.is_enabled()
}
}
@@ -0,0 +1,3 @@
mod view;
pub use view::{init, OpenWarpLaunchModal, OpenWarpLaunchModalEvent};
@@ -0,0 +1,416 @@
use markdown_parser::{
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use warp_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
use warpui::assets::asset_cache::AssetSource;
use warpui::elements::{
Align, CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Expanded, Flex, FormattedTextElement, HighlightedHyperlink, Image,
MainAxisSize, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
Stack, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::keymap::FixedBinding;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
const MODAL_WIDTH: f32 = 420.;
const HERO_HEIGHT: f32 = 92.;
const HERO_IMAGE_PATH: &str = "async/png/onboarding/openwarp_launch_banner.png";
const REPO_URL: &str = "https://github.com/warpdotdev/warp";
const CONTRIBUTING_URL: &str = "https://github.com/warpdotdev/warp/blob/master/CONTRIBUTING.md";
const OZ_URL: &str = "https://oz.warp.dev";
struct InlineLink {
text: &'static str,
url: &'static str,
}
struct FeatureItem {
icon: Icon,
title: &'static str,
description: &'static str,
/// If set, the first occurrence of `text` in the description is rendered as a hyperlink.
inline_link: Option<InlineLink>,
}
const FEATURE_ITEMS: &[FeatureItem] = &[
FeatureItem {
icon: Icon::HeartHand,
title: "Contribute",
description: "Warp's client code is now open source. Get started by using the /feedback skill to open an issue, and follow the contribution guidelines here.",
inline_link: Some(InlineLink {
text: "here",
url: CONTRIBUTING_URL,
}),
},
FeatureItem {
icon: Icon::Oz,
title: "Open Automated Development",
description: "The Warp repo is managed by an agent-first workflow powered by Oz, our cloud agent orchestration platform.",
inline_link: Some(InlineLink {
text: "Oz",
url: OZ_URL,
}),
},
FeatureItem {
icon: Icon::MessageChatSquare,
title: "Introducing 'auto (open-weights)'",
description: "We've added a new auto model that picks the best open weight model for a task, like Kimi or MiniMax.",
inline_link: None,
},
];
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
OpenWarpLaunchModalAction::Close,
id!(OpenWarpLaunchModal::ui_name()),
)]);
}
#[derive(Clone, Debug)]
pub enum OpenWarpLaunchModalAction {
Close,
VisitRepo,
}
#[derive(Clone, Debug)]
pub enum OpenWarpLaunchModalEvent {
Close,
}
struct CloseButtonTheme;
impl ActionButtonTheme for CloseButtonTheme {
fn background(&self, hovered: bool, _appearance: &Appearance) -> Option<Fill> {
if hovered {
Some(Fill::Solid(PhenomenonStyle::modal_close_button_hover()))
} else {
None
}
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
_appearance: &Appearance,
) -> ColorU {
PhenomenonStyle::modal_close_button_text()
}
}
struct CtaButtonTheme;
impl ActionButtonTheme for CtaButtonTheme {
fn background(&self, hovered: bool, _appearance: &Appearance) -> Option<Fill> {
Some(PhenomenonStyle::modal_button_background_fill(hovered))
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
_appearance: &Appearance,
) -> ColorU {
PhenomenonStyle::modal_button_text()
}
}
pub struct OpenWarpLaunchModal {
close_button: ViewHandle<ActionButton>,
cta_button: ViewHandle<ActionButton>,
}
impl OpenWarpLaunchModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let close_button = ctx.add_view(|_ctx| {
ActionButton::new("", CloseButtonTheme)
.with_icon(Icon::X)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(OpenWarpLaunchModalAction::Close))
});
let cta_button = ctx.add_view(|_ctx| {
ActionButton::new("Visit the repo", CtaButtonTheme)
.with_full_width(true)
.on_click(|ctx| ctx.dispatch_typed_action(OpenWarpLaunchModalAction::VisitRepo))
});
Self {
close_button,
cta_button,
}
}
fn render_hero(&self) -> Box<dyn Element> {
let hero = ConstrainedBox::new(
Image::new(
AssetSource::Bundled {
path: HERO_IMAGE_PATH,
},
CacheOption::Original,
)
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.)))
.cover()
.top_aligned()
.finish(),
)
.with_width(MODAL_WIDTH)
.with_height(HERO_HEIGHT)
.finish();
let close_el = Container::new(ChildView::new(&self.close_button).finish())
.with_uniform_padding(4.)
.with_padding_right(2.)
.finish();
let mut hero_stack = Stack::new();
hero_stack.add_child(hero);
hero_stack.add_positioned_child(
close_el,
OffsetPositioning::offset_from_parent(
vec2f(-4., 0.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
hero_stack.finish()
}
fn render_badge(appearance: &Appearance) -> Box<dyn Element> {
Container::new(
Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
.with_color(PhenomenonStyle::modal_badge_text())
.finish(),
)
.with_horizontal_padding(8.)
.with_vertical_padding(2.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background(Fill::Solid(PhenomenonStyle::modal_badge_background()))
.finish()
}
fn render_title(appearance: &Appearance) -> Box<dyn Element> {
Text::new("Warp is now open-source", appearance.ui_font_family(), 20.)
.with_color(PhenomenonStyle::modal_title_text())
.with_style(Properties::default().weight(Weight::Semibold))
.finish()
}
fn render_description(appearance: &Appearance) -> Box<dyn Element> {
Text::new(
"You, our community, can participate in building Warp using an agent-first workflow.",
appearance.ui_font_family(),
14.,
)
.with_color(PhenomenonStyle::modal_feature_description_text())
.finish()
}
/// Splits a plain text string on occurrences of `/feedback`, emitting
/// `inline_code` fragments for each match and plain fragments for the rest.
fn split_inline_code_fragments(text: &str) -> Vec<FormattedTextFragment> {
const CODE_TOKEN: &str = "/feedback";
let mut fragments = Vec::new();
let mut remaining = text;
while let Some(pos) = remaining.find(CODE_TOKEN) {
if pos > 0 {
fragments.push(FormattedTextFragment::plain_text(&remaining[..pos]));
}
fragments.push(FormattedTextFragment {
text: CODE_TOKEN.into(),
styles: FormattedTextStyles {
inline_code: true,
..Default::default()
},
});
remaining = &remaining[pos + CODE_TOKEN.len()..];
}
if !remaining.is_empty() {
fragments.push(FormattedTextFragment::plain_text(remaining));
}
fragments
}
fn render_feature_description(item: &FeatureItem, appearance: &Appearance) -> Box<dyn Element> {
let Some(link) = &item.inline_link else {
return Text::new(item.description, appearance.ui_font_family(), 14.)
.with_color(PhenomenonStyle::modal_feature_description_text())
.finish();
};
// Build a formatted description with an inline hyperlink and inline code.
let (before, after) = item
.description
.split_once(link.text)
.unwrap_or((item.description, ""));
let link_fragment = FormattedTextFragment {
text: link.text.into(),
styles: FormattedTextStyles {
underline: true,
hyperlink: Some(Hyperlink::Url(link.url.into())),
..Default::default()
},
};
let mut fragments = Self::split_inline_code_fragments(before);
fragments.push(link_fragment);
if !after.is_empty() {
fragments.extend(Self::split_inline_code_fragments(after));
}
let formatted = FormattedText::new([FormattedTextLine::Line(fragments)]);
FormattedTextElement::new(
formatted,
14.,
appearance.ui_font_family(),
appearance.monospace_font_family(),
PhenomenonStyle::modal_feature_description_text(),
HighlightedHyperlink::default(),
)
.with_line_height_ratio(1.2)
// Render the inline link in the same color as the description text so it
// blends in; the underline (applied via FormattedTextStyles) still signals it's a link.
.with_hyperlink_font_color(PhenomenonStyle::modal_feature_description_text())
.register_default_click_handlers(|link, _ctx, app| {
app.open_url(&link.url);
})
.finish()
}
fn render_feature_row(item: &FeatureItem, appearance: &Appearance) -> Box<dyn Element> {
let icon_el = ConstrainedBox::new(
item.icon
.to_warpui_icon(Fill::Solid(
PhenomenonStyle::modal_feature_description_text(),
))
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish();
let text_col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(2.)
.with_child(
Text::new_inline(item.title.to_string(), appearance.ui_font_family(), 14.)
.with_color(PhenomenonStyle::modal_feature_title_text())
.finish(),
)
.with_child(Self::render_feature_description(item, appearance))
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(10.)
.with_child(icon_el)
.with_child(Expanded::new(1., text_col).finish())
.finish()
}
fn render_body(&self, appearance: &Appearance) -> Box<dyn Element> {
let mut features_col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(12.);
for item in FEATURE_ITEMS {
features_col.add_child(Self::render_feature_row(item, appearance));
}
let cta = ChildView::new(&self.cta_button).finish();
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(8.)
.with_child(Self::render_badge(appearance))
.with_child(Self::render_title(appearance))
.with_child(Self::render_description(appearance))
.finish(),
)
.with_child(
Container::new(features_col.finish())
.with_margin_top(16.)
.finish(),
)
.with_child(Container::new(cta).with_margin_top(32.).finish())
.finish(),
)
.with_horizontal_padding(32.)
.with_vertical_padding(32.)
.with_background(Fill::Solid(PhenomenonStyle::modal_background()))
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish()
}
}
impl Entity for OpenWarpLaunchModal {
type Event = OpenWarpLaunchModalEvent;
}
impl View for OpenWarpLaunchModal {
fn ui_name() -> &'static str {
"OpenWarpLaunchModal"
}
fn on_focus(&mut self, _focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
ctx.focus_self();
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let card = ConstrainedBox::new(
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(self.render_hero())
.with_child(self.render_body(appearance))
.finish(),
)
.with_background(Fill::Solid(PhenomenonStyle::modal_background()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish(),
)
.with_width(MODAL_WIDTH)
.finish();
Container::new(Align::new(card).finish())
.with_background_color(ColorU::new(18, 18, 18, 128))
.finish()
}
}
impl TypedActionView for OpenWarpLaunchModal {
type Action = OpenWarpLaunchModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
OpenWarpLaunchModalAction::Close => {
ctx.emit(OpenWarpLaunchModalEvent::Close);
}
OpenWarpLaunchModalAction::VisitRepo => {
ctx.open_url(REPO_URL);
ctx.emit(OpenWarpLaunchModalEvent::Close);
}
}
}
}
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
//! Logic to determine the working directory for new terminal sessions.
use super::Workspace;
use crate::terminal::available_shells::AvailableShell;
#[cfg(feature = "local_tty")]
use crate::terminal::available_shells::AvailableShells;
use crate::terminal::session_settings::{NewSessionSource, SessionSettings};
use crate::terminal::ShellLaunchData;
use std::path::PathBuf;
use warpui::SingletonEntity;
use warpui::{AppContext, ViewContext, WindowId};
impl Workspace {
/// Helper function to compute the initial directory for a new session
/// that is inheriting its initial directory from the active session in
/// the given workspace.
fn initial_directory_from_active_session(&self, ctx: &AppContext) -> Option<PathBuf> {
(!self.tabs.is_empty())
.then(|| {
self.active_tab_pane_group().read(ctx, |pane_group, ctx| {
pane_group.active_session_id(ctx).and_then(|base_pane_id| {
pane_group.startup_path_for_new_session(Some(base_pane_id), ctx)
})
})
})
.flatten()
}
/// Helper function to retrieve the shell launch data of the active session,
/// which tells us whether it's a native or WSL session.
fn shell_launch_info_from_active_session(&self, ctx: &AppContext) -> Option<ShellLaunchData> {
(!self.tabs.is_empty())
.then(|| {
self.active_tab_pane_group().read(ctx, |pane_group, ctx| {
pane_group.active_session_id(ctx).and_then(|base_pane_id| {
pane_group.launch_data_for_session(base_pane_id, ctx)
})
})
})
.flatten()
}
/// Helper function to compute the initial directory for a new session.
/// Returns Some(path) if inheriting the initial directory from an active
/// session or using the user's custom path setting,
/// and None if the default startup directory (the user's home directory) should be used.
pub(super) fn get_new_tab_startup_directory(
&mut self,
new_session_source: NewSessionSource,
previous_session_window_id: Option<WindowId>,
chosen_shell: Option<&AvailableShell>,
ctx: &mut ViewContext<Self>,
) -> Option<PathBuf> {
// Get the Workspace from the window that hosted the previously-active
// session.
let active_session_info = match previous_session_window_id {
// If the previous window is the one hosting this workspace, don't
// do any indirection through AppContext.
Some(window_id) if window_id == ctx.window_id() => Some((
self.initial_directory_from_active_session(ctx),
self.shell_launch_info_from_active_session(ctx),
)),
// Otherwise, lookup the Workspace in that window and query it.
Some(window_id) => {
let workspace_handle = ctx
.views_of_type::<Workspace>(window_id)
.and_then(|views| views.first().cloned());
workspace_handle.map(|workspace| {
workspace.read(ctx, |workspace, ctx| {
(
workspace.initial_directory_from_active_session(ctx),
workspace.shell_launch_info_from_active_session(ctx),
)
})
})
}
None => None,
};
let (prev_session_working_directory, prev_session_shell) =
active_session_info.unwrap_or_default();
cfg_if::cfg_if! {
if #[cfg(feature = "local_tty")] {
let is_wsl = new_session_shell(chosen_shell, ctx)
.wsl_distro()
.is_some();
} else {
let is_wsl = false;
}
}
let is_same_system = same_system(prev_session_shell.as_ref(), chosen_shell, ctx);
compute_startup_directory_from_prev_session(
new_session_source,
if is_same_system {
prev_session_working_directory
} else {
None
},
is_wsl,
ctx,
)
}
}
/// The shell to be used in the new session,
/// based on the shell explicitly chosen by the user or
/// the default startup shell specified in settings.
#[cfg(feature = "local_tty")]
fn new_session_shell(chosen_shell: Option<&AvailableShell>, ctx: &AppContext) -> AvailableShell {
chosen_shell.cloned().unwrap_or_else(move || {
AvailableShells::handle(ctx).read(ctx, |shells, ctx| shells.get_user_preferred_shell(ctx))
})
}
/// Windows-specific helper function to determine whether the old and
/// new shell sessions will exist in the same system, i.e. whether
/// they're both on native Windows or both in the same WSL distribution.
///
/// Returns `true` if `old_session_launch_data` is `None`.
#[cfg(feature = "local_tty")]
fn same_system(
old_session_launch_data: Option<&ShellLaunchData>,
chosen_shell: Option<&AvailableShell>,
ctx: &AppContext,
) -> bool {
// If there's no prior session, there is no prior system.
// We're not crossing a system boundary, so return true.
let Some(old_launch_data) = old_session_launch_data else {
return true;
};
let wsl_distro = new_session_shell(chosen_shell, ctx).wsl_distro();
match old_launch_data {
ShellLaunchData::WSL { distro: old_distro } => {
wsl_distro.is_some_and(|new_distro| new_distro == *old_distro)
}
_ => wsl_distro.is_none(),
}
}
#[cfg(not(feature = "local_tty"))]
const fn same_system(
_old_session_launch_data: Option<&ShellLaunchData>,
_chosen_shell: Option<&AvailableShell>,
_ctx: &AppContext,
) -> bool {
true
}
/// Helper function to compute the actual startup directory for the
/// new session based on the user's settings.
fn compute_startup_directory_from_prev_session(
new_session_source: NewSessionSource,
initial_directory_from_prev_session: Option<PathBuf>,
ignore_custom_directory: bool,
ctx: &ViewContext<Workspace>,
) -> Option<PathBuf> {
SessionSettings::handle(ctx).read(ctx, |settings, _ctx| {
settings
.working_directory_config
.initial_directory_for_new_session(
new_session_source,
initial_directory_from_prev_session,
ignore_custom_directory,
)
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use warp_core::features::FeatureFlag;
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
use crate::workspace::tab_settings::{
VerticalTabsCompactSubtitle, VerticalTabsDisplayGranularity, VerticalTabsPrimaryInfo,
VerticalTabsTabItemMode, VerticalTabsViewMode,
};
/// Which display option on the vertical tabs settings popup the user changed,
/// along with the new value they picked.
#[derive(Clone, Copy, Debug)]
pub enum VerticalTabsDisplayOption {
DisplayGranularity(VerticalTabsDisplayGranularity),
TabItemMode(VerticalTabsTabItemMode),
ViewMode(VerticalTabsViewMode),
PrimaryInfo(VerticalTabsPrimaryInfo),
CompactSubtitle(VerticalTabsCompactSubtitle),
ShowPrLink(bool),
ShowDiffStats(bool),
ShowDetailsOnHover(bool),
}
impl VerticalTabsDisplayOption {
fn option_name(&self) -> &'static str {
match self {
Self::DisplayGranularity(_) => "display_granularity",
Self::TabItemMode(_) => "tab_item_mode",
Self::ViewMode(_) => "view_mode",
Self::PrimaryInfo(_) => "primary_info",
Self::CompactSubtitle(_) => "compact_subtitle",
Self::ShowPrLink(_) => "show_pr_link",
Self::ShowDiffStats(_) => "show_diff_stats",
Self::ShowDetailsOnHover(_) => "show_details_on_hover",
}
}
fn serialized_value(&self) -> Value {
match self {
Self::DisplayGranularity(VerticalTabsDisplayGranularity::Panes) => json!("panes"),
Self::DisplayGranularity(VerticalTabsDisplayGranularity::Tabs) => json!("tabs"),
Self::TabItemMode(VerticalTabsTabItemMode::FocusedSession) => json!("focused_session"),
Self::TabItemMode(VerticalTabsTabItemMode::Summary) => json!("summary"),
Self::ViewMode(VerticalTabsViewMode::Compact) => json!("compact"),
Self::ViewMode(VerticalTabsViewMode::Expanded) => json!("expanded"),
Self::PrimaryInfo(VerticalTabsPrimaryInfo::Command) => json!("command"),
Self::PrimaryInfo(VerticalTabsPrimaryInfo::WorkingDirectory) => {
json!("working_directory")
}
Self::PrimaryInfo(VerticalTabsPrimaryInfo::Branch) => json!("branch"),
Self::CompactSubtitle(VerticalTabsCompactSubtitle::Branch) => json!("branch"),
Self::CompactSubtitle(VerticalTabsCompactSubtitle::WorkingDirectory) => {
json!("working_directory")
}
Self::CompactSubtitle(VerticalTabsCompactSubtitle::Command) => json!("command"),
Self::ShowPrLink(value) => json!(value),
Self::ShowDiffStats(value) => json!(value),
Self::ShowDetailsOnHover(value) => json!(value),
}
}
}
/// Where in the vertical tabs UI a clickable diff-stats or GitHub PR chip
/// was rendered when the user clicked it.
#[derive(Clone, Copy, Debug)]
pub enum VerticalTabsChipEntrypoint {
/// The chip was rendered on a row representing a single pane
/// (display granularity: Panes).
Pane,
/// The chip was rendered on a row representing a tab group
/// (display granularity: Tabs).
Tab,
/// The chip was rendered inside the detail sidecar that appears on row hover.
DetailsSidecar,
}
impl VerticalTabsChipEntrypoint {
fn serialized(&self) -> &'static str {
match self {
Self::Pane => "pane",
Self::Tab => "tab",
Self::DetailsSidecar => "details_sidecar",
}
}
}
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub enum VerticalTabsTelemetryEvent {
/// The user updated a display option in the vertical tabs settings popup.
DisplayOptionChanged(VerticalTabsDisplayOption),
/// The user clicked the diff stats chip on a vertical tabs row or the detail sidecar.
DiffStatsChipClicked {
entrypoint: VerticalTabsChipEntrypoint,
},
/// The user clicked the GitHub PR chip on a vertical tabs row or the detail sidecar.
PrChipClicked {
entrypoint: VerticalTabsChipEntrypoint,
},
}
impl TelemetryEvent for VerticalTabsTelemetryEvent {
fn name(&self) -> &'static str {
VerticalTabsTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<Value> {
match self {
Self::DisplayOptionChanged(option) => Some(json!({
"option": option.option_name(),
"value": option.serialized_value(),
})),
Self::DiffStatsChipClicked { entrypoint } => Some(json!({
"entrypoint": entrypoint.serialized(),
})),
Self::PrChipClicked { entrypoint } => Some(json!({
"entrypoint": entrypoint.serialized(),
})),
}
}
fn description(&self) -> &'static str {
VerticalTabsTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
VerticalTabsTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
false
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
warp_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for VerticalTabsTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
Self::DisplayOptionChanged => "VerticalTabs.DisplayOptionChanged",
Self::DiffStatsChipClicked => "VerticalTabs.DiffStatsChipClicked",
Self::PrChipClicked => "VerticalTabs.PrChipClicked",
}
}
fn description(&self) -> &'static str {
match self {
Self::DisplayOptionChanged => {
"User updated a display option in the vertical tabs settings popup"
}
Self::DiffStatsChipClicked => {
"User clicked a diff stats chip in the vertical tabs panel or detail sidecar"
}
Self::PrChipClicked => {
"User clicked a GitHub PR chip in the vertical tabs panel or detail sidecar"
}
}
}
fn enablement_state(&self) -> EnablementState {
EnablementState::Flag(FeatureFlag::VerticalTabs)
}
}
warp_core::register_telemetry_event!(VerticalTabsTelemetryEvent);
@@ -0,0 +1,990 @@
use crate::context_chips::display_chip::GitLineChanges;
use crate::pane_group::pane::IPaneType;
use crate::pane_group::{PaneId, TerminalPaneId};
use crate::safe_triangle::SafeTriangle;
use crate::terminal::CLIAgent;
use crate::workspace::tab_settings::VerticalTabsDisplayGranularity;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use std::path::PathBuf;
use warpui::elements::PositionedElementOffsetBounds;
use warpui::EntityId;
use super::{
branch_label_display, coalesce_summary_branch_entries, code_detail_kind_label,
compact_branch_subtitle_display, detail_sidecar_width_and_bounds,
detail_target_for_hovered_row, format_summary_primary_labels,
non_terminal_search_text_fragments, pane_ids_for_display_granularity,
pane_search_text_fragments, preferred_agent_tab_titles, search_fragments_contain_query,
select_summary_pane_kind_icons, should_keep_detail_sidecar_visible_for_mouse_position,
summary_overflow_count, summary_search_text_fragments, terminal_kind_badge_label,
terminal_primary_line_data, terminal_pull_request_badge_label, terminal_search_text_fragments,
terminal_title_fallback_font, uses_outer_group_container, visible_pane_ids_for_detail_target,
vtab_diff_stats_text, AgentTabTextPreference, SummaryPaneKind, SummaryPaneKindIcons,
TerminalAgentText, TerminalPrimaryLineData, TerminalPrimaryLineFont, VerticalTabsDetailTarget,
VerticalTabsDetailTargetKind, VerticalTabsSummaryBranchEntry, VerticalTabsSummaryData,
};
fn pane_id() -> PaneId {
TerminalPaneId::dummy_terminal_pane_id().into()
}
fn code_summary_kind(title: &str) -> SummaryPaneKind {
SummaryPaneKind::Code {
title: title.to_string(),
}
}
#[test]
fn summary_pane_kind_icons_render_single_icon_for_homogeneous_tabs() {
assert_eq!(
select_summary_pane_kind_icons([
(EntityId::from_usize(10), SummaryPaneKind::Terminal),
(EntityId::from_usize(20), SummaryPaneKind::Terminal),
]),
Some(SummaryPaneKindIcons::Single(SummaryPaneKind::Terminal))
);
}
#[test]
fn summary_pane_kind_icons_pick_two_oldest_distinct_pane_kinds() {
assert_eq!(
select_summary_pane_kind_icons([
(EntityId::from_usize(30), SummaryPaneKind::Terminal),
(EntityId::from_usize(20), code_summary_kind("main.rs")),
(
EntityId::from_usize(40),
SummaryPaneKind::Notebook { is_plan: false },
),
(EntityId::from_usize(10), SummaryPaneKind::Terminal),
]),
Some(SummaryPaneKindIcons::Pair {
primary: SummaryPaneKind::Terminal,
secondary: code_summary_kind("main.rs"),
})
);
}
#[test]
fn summary_pane_kind_icons_recompute_when_oldest_kind_is_removed() {
assert_eq!(
select_summary_pane_kind_icons([
(EntityId::from_usize(20), code_summary_kind("main.rs")),
(EntityId::from_usize(30), SummaryPaneKind::Terminal),
]),
Some(SummaryPaneKindIcons::Pair {
primary: code_summary_kind("main.rs"),
secondary: SummaryPaneKind::Terminal,
})
);
}
#[test]
fn summary_pane_kind_icons_distinguish_agent_terminals_from_plain_terminals() {
assert_eq!(
select_summary_pane_kind_icons([
(EntityId::from_usize(10), SummaryPaneKind::Terminal),
(
EntityId::from_usize(20),
SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
},
),
(
EntityId::from_usize(30),
SummaryPaneKind::OzAgent { is_ambient: false },
),
]),
Some(SummaryPaneKindIcons::Pair {
primary: SummaryPaneKind::Terminal,
secondary: SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
},
})
);
}
#[test]
fn preferred_agent_tab_titles_default_to_title_like_text() {
let agent_text = TerminalAgentText {
conversation_display_title: Some("Generated Oz title".to_string()),
conversation_latest_user_prompt: Some("Latest Oz prompt".to_string()),
cli_agent_title: Some("CLI summary".to_string()),
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
is_oz_agent: true,
cli_agent: Some(CLIAgent::Claude),
};
assert_eq!(
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::ConversationTitle),
(
Some("Generated Oz title".to_string()),
Some("CLI summary".to_string())
)
);
}
#[test]
fn preferred_agent_tab_titles_do_not_use_cli_prompt_when_disabled() {
let agent_text = TerminalAgentText {
conversation_display_title: None,
conversation_latest_user_prompt: None,
cli_agent_title: None,
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
is_oz_agent: false,
cli_agent: Some(CLIAgent::Claude),
};
assert_eq!(
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::ConversationTitle),
(None, None)
);
}
#[test]
fn terminal_primary_line_uses_terminal_title_when_disabled_cli_has_only_prompt() {
let agent_text = TerminalAgentText {
conversation_display_title: None,
conversation_latest_user_prompt: None,
cli_agent_title: None,
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
is_oz_agent: false,
cli_agent: Some(CLIAgent::Claude),
};
let (conversation_title, cli_title) =
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::ConversationTitle);
let line = terminal_primary_line_data(
false,
conversation_title,
cli_title,
"Generated Claude Code title",
"~/warp",
terminal_title_fallback_font(&agent_text),
Some("claude".to_string()),
);
assert_eq!(line.text(), "Generated Claude Code title");
assert!(matches!(
line,
TerminalPrimaryLineData::Text {
font: TerminalPrimaryLineFont::Ui,
..
}
));
}
#[test]
fn preferred_agent_tab_titles_use_latest_prompt_when_enabled() {
let agent_text = TerminalAgentText {
conversation_display_title: Some("Generated Oz title".to_string()),
conversation_latest_user_prompt: Some("Latest Oz prompt".to_string()),
cli_agent_title: Some("CLI summary".to_string()),
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
is_oz_agent: true,
cli_agent: Some(CLIAgent::Claude),
};
assert_eq!(
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::LatestUserPrompt),
(
Some("Latest Oz prompt".to_string()),
Some("Latest CLI prompt".to_string())
)
);
}
#[test]
fn terminal_primary_line_uses_cli_prompt_when_enabled_cli_has_prompt() {
let agent_text = TerminalAgentText {
conversation_display_title: None,
conversation_latest_user_prompt: None,
cli_agent_title: None,
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
is_oz_agent: false,
cli_agent: Some(CLIAgent::Claude),
};
let (conversation_title, cli_title) =
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::LatestUserPrompt);
let line = terminal_primary_line_data(
false,
conversation_title,
cli_title,
"Generated Claude Code title",
"~/warp",
terminal_title_fallback_font(&agent_text),
Some("claude".to_string()),
);
assert_eq!(line.text(), "Latest CLI prompt");
}
#[test]
fn terminal_primary_line_uses_cli_prompt_when_enabled_cli_is_long_running() {
let agent_text = TerminalAgentText {
conversation_display_title: None,
conversation_latest_user_prompt: None,
cli_agent_title: None,
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
is_oz_agent: false,
cli_agent: Some(CLIAgent::Claude),
};
let (conversation_title, cli_title) =
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::LatestUserPrompt);
let line = terminal_primary_line_data(
true,
conversation_title,
cli_title,
"Generated Claude Code title",
"~/warp",
terminal_title_fallback_font(&agent_text),
Some("claude".to_string()),
);
assert_eq!(line.text(), "Latest CLI prompt");
}
#[test]
fn preferred_agent_tab_titles_fall_back_when_preferred_text_is_missing() {
let agent_text = TerminalAgentText {
conversation_display_title: Some("Generated Oz title".to_string()),
conversation_latest_user_prompt: None,
cli_agent_title: None,
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
is_oz_agent: true,
cli_agent: Some(CLIAgent::Claude),
};
assert_eq!(
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::LatestUserPrompt),
(
Some("Generated Oz title".to_string()),
Some("Latest CLI prompt".to_string())
)
);
}
fn pane_type_supports_vertical_tabs_detail_sidecar(pane_type: IPaneType) -> bool {
matches!(
pane_type,
IPaneType::Terminal
| IPaneType::Code
| IPaneType::Notebook
| IPaneType::Workflow
| IPaneType::EnvVarCollection
| IPaneType::AIFact
| IPaneType::AIDocument
)
}
fn collect_normalized_unique_summary_texts(
texts: impl IntoIterator<Item = impl AsRef<str>>,
) -> Vec<String> {
texts
.into_iter()
.filter_map(|text| {
let normalized = text
.as_ref()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
(!normalized.is_empty()).then_some(normalized)
})
.fold(Vec::new(), |mut values, normalized| {
if !values.contains(&normalized) {
values.push(normalized);
}
values
})
}
#[test]
fn detail_sidecar_supports_terminal_code_and_warp_drive_object_panes() {
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
IPaneType::Terminal
));
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
IPaneType::Code
));
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
IPaneType::Notebook
));
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
IPaneType::Workflow
));
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
IPaneType::EnvVarCollection
));
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
IPaneType::AIFact
));
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
IPaneType::AIDocument
));
assert!(!pane_type_supports_vertical_tabs_detail_sidecar(
IPaneType::Settings
));
}
#[test]
fn code_detail_kind_label_uses_programming_language_display_name() {
assert_eq!(
code_detail_kind_label("block_id.rs"),
Some("Rust".to_string())
);
assert_eq!(
code_detail_kind_label("Dockerfile"),
Some("Dockerfile".to_string())
);
}
#[test]
fn code_detail_kind_label_returns_none_when_language_is_unknown() {
assert_eq!(code_detail_kind_label("notes.txt"), None);
}
#[test]
fn detail_target_matches_panes_granularity() {
let pane_group_id = EntityId::new();
let hovered_pane_id = pane_id();
assert_eq!(
detail_target_for_hovered_row(
pane_group_id,
hovered_pane_id,
VerticalTabsDisplayGranularity::Panes,
),
VerticalTabsDetailTarget::Pane {
pane_group_id,
pane_id: hovered_pane_id,
}
);
}
#[test]
fn detail_target_matches_tabs_granularity() {
let pane_group_id = EntityId::new();
let hovered_pane_id = pane_id();
assert_eq!(
detail_target_for_hovered_row(
pane_group_id,
hovered_pane_id,
VerticalTabsDisplayGranularity::Tabs,
),
VerticalTabsDetailTarget::Tab {
pane_group_id,
source_pane_id: hovered_pane_id,
}
);
}
#[test]
fn pane_detail_target_returns_hovered_pane_when_supported() {
let hovered_pane_id = pane_id();
assert_eq!(
visible_pane_ids_for_detail_target(
&[hovered_pane_id],
hovered_pane_id,
VerticalTabsDetailTargetKind::Pane,
|pane_id| pane_id == hovered_pane_id,
),
Some(vec![hovered_pane_id])
);
}
#[test]
fn pane_detail_target_returns_none_when_hovered_pane_is_not_supported() {
let hovered_pane_id = pane_id();
assert_eq!(
visible_pane_ids_for_detail_target(
&[hovered_pane_id],
hovered_pane_id,
VerticalTabsDetailTargetKind::Pane,
|_| false,
),
None
);
}
#[test]
fn tab_detail_target_returns_all_visible_panes_when_every_pane_is_supported() {
let pane_1 = pane_id();
let pane_2 = pane_id();
let pane_3 = pane_id();
let visible_pane_ids = vec![pane_1, pane_2, pane_3];
assert_eq!(
visible_pane_ids_for_detail_target(
&visible_pane_ids,
pane_2,
VerticalTabsDetailTargetKind::Tab,
|_| true,
),
Some(visible_pane_ids)
);
}
#[test]
fn tab_detail_target_returns_none_for_mixed_support_tabs() {
let pane_1 = pane_id();
let pane_2 = pane_id();
let pane_3 = pane_id();
assert_eq!(
visible_pane_ids_for_detail_target(
&[pane_1, pane_2, pane_3],
pane_2,
VerticalTabsDetailTargetKind::Tab,
|pane_id| pane_id != pane_3,
),
None
);
}
#[test]
fn panes_granularity_returns_all_visible_panes_in_order() {
let pane_1 = pane_id();
let pane_2 = pane_id();
let pane_3 = pane_id();
let visible_pane_ids = vec![pane_1, pane_2, pane_3];
assert_eq!(
pane_ids_for_display_granularity(
&visible_pane_ids,
pane_2,
VerticalTabsDisplayGranularity::Panes,
),
visible_pane_ids
);
}
#[test]
fn tabs_granularity_returns_focused_pane_when_present() {
let pane_1 = pane_id();
let pane_2 = pane_id();
let pane_3 = pane_id();
assert_eq!(
pane_ids_for_display_granularity(
&[pane_1, pane_2, pane_3],
pane_2,
VerticalTabsDisplayGranularity::Tabs,
),
vec![pane_2]
);
}
#[test]
fn tabs_granularity_falls_back_to_first_visible_pane_when_focused_pane_is_absent() {
let pane_1 = pane_id();
let pane_2 = pane_id();
let pane_3 = pane_id();
let focused_pane = pane_id();
assert_eq!(
pane_ids_for_display_granularity(
&[pane_1, pane_2, pane_3],
focused_pane,
VerticalTabsDisplayGranularity::Tabs,
),
vec![pane_1]
);
}
#[test]
fn tabs_granularity_returns_empty_for_empty_visible_panes() {
assert_eq!(
pane_ids_for_display_granularity(&[], pane_id(), VerticalTabsDisplayGranularity::Tabs,),
Vec::<PaneId>::new()
);
}
#[test]
fn detail_sidecar_uses_default_width_when_space_allows() {
let (width, bounds) = detail_sidecar_width_and_bounds(400.);
assert_eq!(width, 320.);
assert!(matches!(
bounds,
PositionedElementOffsetBounds::WindowBySize
));
}
#[test]
fn detail_sidecar_shrinks_to_fit_before_hitting_min_width() {
let (width, bounds) = detail_sidecar_width_and_bounds(280.);
assert_eq!(width, 280.);
assert!(matches!(
bounds,
PositionedElementOffsetBounds::WindowBySize
));
}
#[test]
fn detail_sidecar_stops_shrinking_at_min_width_and_allows_clipping() {
let (width, bounds) = detail_sidecar_width_and_bounds(180.);
assert_eq!(width, 240.);
assert!(matches!(bounds, PositionedElementOffsetBounds::Unbounded));
}
#[test]
fn detail_sidecar_visibility_helper_keeps_sidecar_visible_inside_sidecar_bounds() {
let row_rect = RectF::new(Vector2F::new(0., 100.), Vector2F::new(100., 40.));
let sidecar_rect = RectF::new(Vector2F::new(120., 50.), Vector2F::new(180., 220.));
let mut safe_triangle = SafeTriangle::new();
assert!(should_keep_detail_sidecar_visible_for_mouse_position(
Vector2F::new(200., 120.),
Some(row_rect),
Some(sidecar_rect),
&mut safe_triangle,
));
}
#[test]
fn detail_sidecar_visibility_helper_keeps_sidecar_visible_in_safe_triangle() {
let row_rect = RectF::new(Vector2F::new(0., 100.), Vector2F::new(100., 40.));
let sidecar_rect = RectF::new(Vector2F::new(120., 50.), Vector2F::new(180., 220.));
let mut safe_triangle = SafeTriangle::new();
safe_triangle.set_target_rect(Some(sidecar_rect));
safe_triangle.update_position(Vector2F::new(90., 120.));
assert!(should_keep_detail_sidecar_visible_for_mouse_position(
Vector2F::new(110., 120.),
Some(row_rect),
Some(sidecar_rect),
&mut safe_triangle,
));
}
#[test]
fn detail_sidecar_visibility_helper_clears_sidecar_outside_row_sidecar_and_safe_triangle() {
let row_rect = RectF::new(Vector2F::new(0., 100.), Vector2F::new(100., 40.));
let sidecar_rect = RectF::new(Vector2F::new(120., 50.), Vector2F::new(180., 220.));
let mut safe_triangle = SafeTriangle::new();
safe_triangle.update_position(Vector2F::new(200., 120.));
assert!(!should_keep_detail_sidecar_visible_for_mouse_position(
Vector2F::new(340., 120.),
Some(row_rect),
Some(sidecar_rect),
&mut safe_triangle,
));
}
#[test]
fn panes_granularity_uses_outer_group_container() {
assert!(uses_outer_group_container(
VerticalTabsDisplayGranularity::Panes
));
}
#[test]
fn tabs_granularity_does_not_use_outer_group_container() {
assert!(!uses_outer_group_container(
VerticalTabsDisplayGranularity::Tabs
));
}
#[test]
fn terminal_primary_line_prefers_cli_agent_display_title() {
let line = terminal_primary_line_data(
false,
None,
Some("Review the failing tests".to_string()),
"~/warp",
"~/warp",
TerminalPrimaryLineFont::Monospace,
Some("cargo nextest run".to_string()),
);
assert_eq!(line.text(), "Review the failing tests");
}
#[test]
fn terminal_primary_line_prefers_cli_agent_display_title_over_conversation_title() {
let line = terminal_primary_line_data(
false,
Some("Review the failing tests".to_string()),
Some("Summarize the failures".to_string()),
"~/warp",
"~/warp",
TerminalPrimaryLineFont::Monospace,
Some("cargo nextest run".to_string()),
);
assert_eq!(line.text(), "Summarize the failures");
}
#[test]
fn terminal_primary_line_falls_through_to_terminal_title_when_cli_agent_has_no_plugin_data() {
let line = terminal_primary_line_data(
false,
None,
None,
"codex - ~/warp",
"~/warp",
TerminalPrimaryLineFont::Monospace,
Some("cargo nextest run".to_string()),
);
assert_eq!(line.text(), "codex - ~/warp");
}
#[test]
fn terminal_primary_line_uses_terminal_title_as_fallback() {
let line = terminal_primary_line_data(
false,
None,
None,
"nvim src/workspace/view/vertical_tabs.rs",
"~/warp",
TerminalPrimaryLineFont::Monospace,
Some("cargo nextest run".to_string()),
);
assert_eq!(line.text(), "nvim src/workspace/view/vertical_tabs.rs");
}
#[test]
fn terminal_primary_line_uses_last_completed_command_when_shell_title_matches_working_directory() {
let line = terminal_primary_line_data(
false,
None,
None,
"~/warp",
"~/warp",
TerminalPrimaryLineFont::Monospace,
Some("cargo nextest run".to_string()),
);
assert_eq!(line.text(), "cargo nextest run");
}
#[test]
fn terminal_primary_line_falls_back_to_new_session() {
let line = terminal_primary_line_data(
false,
None,
None,
"~/warp",
"~/warp",
TerminalPrimaryLineFont::Monospace,
None,
);
assert_eq!(line.text(), "New session");
assert!(matches!(
line,
TerminalPrimaryLineData::Text {
font: TerminalPrimaryLineFont::Ui,
..
}
));
}
#[test]
fn terminal_primary_line_uses_monospace_for_last_completed_command() {
let line = terminal_primary_line_data(
false,
None,
None,
"~/warp",
"~/warp",
TerminalPrimaryLineFont::Monospace,
Some("cargo nextest run".to_string()),
);
assert!(matches!(
line,
TerminalPrimaryLineData::Text {
font: TerminalPrimaryLineFont::Monospace,
..
}
));
}
#[test]
fn terminal_search_fragments_include_rendered_terminal_badges() {
let fragments = terminal_search_text_fragments(
"Review the failing tests".to_string(),
"~/warp".to_string(),
Some("main".to_string()),
terminal_kind_badge_label(false, Some(CLIAgent::Claude)),
Some(terminal_pull_request_badge_label(
"https://github.com/warpdotdev/warp-internal/pull/12345",
)),
Some(GitLineChanges {
files_changed: 1,
lines_added: 2,
lines_removed: 3,
}),
);
assert!(search_fragments_contain_query(&fragments, "claude"));
assert!(search_fragments_contain_query(
&fragments,
"review the failing tests"
));
assert!(search_fragments_contain_query(&fragments, "#12345"));
assert!(search_fragments_contain_query(&fragments, "+2"));
assert!(search_fragments_contain_query(&fragments, "-3"));
}
#[test]
fn pane_search_fragments_prepend_custom_title_and_keep_generated_metadata() {
let fragments = pane_search_text_fragments(
Some("Production API"),
vec![
"cargo nextest run".to_string(),
"~/warp".to_string(),
"Claude".to_string(),
],
);
assert_eq!(fragments[0], "Production API");
assert!(search_fragments_contain_query(&fragments, "production api"));
assert!(search_fragments_contain_query(&fragments, "cargo nextest"));
assert!(search_fragments_contain_query(&fragments, "~/warp"));
assert!(search_fragments_contain_query(&fragments, "claude"));
}
#[test]
fn pane_search_fragments_dedupe_custom_title_against_generated_text() {
assert_eq!(
pane_search_text_fragments(
Some(" Production API "),
vec![
"Production API".to_string(),
"~/warp".to_string(),
"~/warp".to_string(),
],
),
vec!["Production API".to_string(), "~/warp".to_string()]
);
}
#[test]
fn non_terminal_search_fragments_only_include_rendered_text() {
let fragments = non_terminal_search_text_fragments("Pane title", "and 2 more");
assert!(search_fragments_contain_query(&fragments, "pane title"));
assert!(search_fragments_contain_query(&fragments, "and 2 more"));
assert!(!search_fragments_contain_query(&fragments, "notebook"));
assert!(!search_fragments_contain_query(&fragments, "unsaved"));
}
#[test]
fn diff_stats_text_matches_rendered_badge_text() {
assert_eq!(
vtab_diff_stats_text(&GitLineChanges {
files_changed: 1,
lines_added: 2,
lines_removed: 3,
}),
"+2 -3"
);
assert_eq!(
vtab_diff_stats_text(&GitLineChanges {
files_changed: 1,
lines_added: 0,
lines_removed: 0,
}),
"0"
);
}
#[test]
fn branch_label_display_falls_back_without_branch_icon() {
assert_eq!(
branch_label_display(None, "~/warp"),
("~/warp".to_string(), false)
);
assert_eq!(
branch_label_display(Some(""), "~/warp"),
("~/warp".to_string(), false)
);
assert_eq!(
branch_label_display(Some("main"), "~/warp"),
("main".to_string(), true)
);
}
#[test]
fn compact_branch_subtitle_falls_back_to_working_directory_without_branch_icon() {
assert_eq!(
compact_branch_subtitle_display(None, Some("~/warp")),
Some(("~/warp".to_string(), false))
);
assert_eq!(
compact_branch_subtitle_display(Some(""), Some("~/warp")),
Some(("~/warp".to_string(), false))
);
assert_eq!(
compact_branch_subtitle_display(Some("main"), Some("~/warp")),
Some(("main".to_string(), true))
);
}
#[test]
fn collect_normalized_unique_summary_texts_dedupes_after_whitespace_normalization() {
assert_eq!(
collect_normalized_unique_summary_texts([
" cargo test ",
"cargo test",
"",
" git status ",
]),
vec!["cargo test".to_string(), "git status".to_string()]
);
}
#[test]
fn collect_normalized_unique_summary_texts_preserves_first_seen_order() {
assert_eq!(
collect_normalized_unique_summary_texts([
"~/warp-internal",
"~/warp-server",
"~/warp-internal",
"~/warp-terraform",
]),
vec![
"~/warp-internal".to_string(),
"~/warp-server".to_string(),
"~/warp-terraform".to_string(),
]
);
}
#[test]
fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
let repo_a = PathBuf::from("/tmp/repo-a");
let repo_b = PathBuf::from("/tmp/repo-b");
let entries = vec![
VerticalTabsSummaryBranchEntry {
repo_path: repo_a.clone(),
branch_name: "main".to_string(),
diff_stats: None,
pull_request_label: None,
},
VerticalTabsSummaryBranchEntry {
repo_path: repo_a.clone(),
branch_name: "main".to_string(),
diff_stats: Some(GitLineChanges {
files_changed: 1,
lines_added: 2,
lines_removed: 3,
}),
pull_request_label: Some("#123".to_string()),
},
VerticalTabsSummaryBranchEntry {
repo_path: repo_b.clone(),
branch_name: "main".to_string(),
diff_stats: Some(GitLineChanges {
files_changed: 4,
lines_added: 5,
lines_removed: 6,
}),
pull_request_label: Some("#456".to_string()),
},
];
assert_eq!(
coalesce_summary_branch_entries(entries),
vec![
VerticalTabsSummaryBranchEntry {
repo_path: repo_a,
branch_name: "main".to_string(),
diff_stats: Some(GitLineChanges {
files_changed: 1,
lines_added: 2,
lines_removed: 3,
}),
pull_request_label: Some("#123".to_string()),
},
VerticalTabsSummaryBranchEntry {
repo_path: repo_b,
branch_name: "main".to_string(),
diff_stats: Some(GitLineChanges {
files_changed: 4,
lines_added: 5,
lines_removed: 6,
}),
pull_request_label: Some("#456".to_string()),
},
]
);
}
#[test]
fn format_summary_primary_labels_appends_overflow_count() {
let labels = vec![
"Claude".to_string(),
"Oz".to_string(),
"cargo".to_string(),
"code review".to_string(),
"tests".to_string(),
];
assert_eq!(
format_summary_primary_labels(&labels, 4),
Some("Claude • Oz • cargo • code review + 1 more".to_string())
);
assert_eq!(summary_overflow_count(labels.len(), 4), 1);
}
#[test]
fn summary_search_fragments_include_hidden_overflow_values() {
let summary = VerticalTabsSummaryData {
primary_labels: vec![
"Claude".to_string(),
"Oz".to_string(),
"cargo".to_string(),
"code review".to_string(),
"hidden work".to_string(),
],
working_directories: vec!["~/warp-internal".to_string(), "~/warp-server".to_string()],
branch_entries: vec![
VerticalTabsSummaryBranchEntry {
repo_path: PathBuf::from("/tmp/repo-a"),
branch_name: "main".to_string(),
diff_stats: Some(GitLineChanges {
files_changed: 1,
lines_added: 2,
lines_removed: 3,
}),
pull_request_label: Some("#123".to_string()),
},
VerticalTabsSummaryBranchEntry {
repo_path: PathBuf::from("/tmp/repo-b"),
branch_name: "feature/hidden".to_string(),
diff_stats: None,
pull_request_label: None,
},
VerticalTabsSummaryBranchEntry {
repo_path: PathBuf::from("/tmp/repo-c"),
branch_name: "cleanup".to_string(),
diff_stats: None,
pull_request_label: None,
},
VerticalTabsSummaryBranchEntry {
repo_path: PathBuf::from("/tmp/repo-d"),
branch_name: "hidden-branch".to_string(),
diff_stats: None,
pull_request_label: Some("#789".to_string()),
},
],
};
let fragments = summary_search_text_fragments(&summary, Some("Custom tab"));
assert!(search_fragments_contain_query(&fragments, "custom tab"));
assert!(search_fragments_contain_query(&fragments, "hidden work"));
assert!(search_fragments_contain_query(&fragments, "hidden-branch"));
assert!(search_fragments_contain_query(&fragments, "#789"));
assert!(search_fragments_contain_query(&fragments, "+2"));
assert!(search_fragments_contain_query(&fragments, "-3"));
}
+208
View File
@@ -0,0 +1,208 @@
//! WASM-only view functions for the Workspace.
use warpui::elements::{ChildView, Element};
use warpui::{AppContext, SingletonEntity, ViewContext, ViewHandle};
use warp_core::channel::ChannelState;
use crate::uri::browser_url_handler::parse_current_url;
use super::PanelPosition;
use crate::ai::agent_conversations_model::AgentConversationsModel;
use crate::ai::conversation_details_panel::{
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
};
use crate::terminal::TerminalView;
use crate::ui_components::icons;
use crate::view_components::action_button::{
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
};
use crate::wasm_nux_dialog::{WasmNUXDialog, WasmNUXDialogEvent};
use crate::workspace::action::WorkspaceAction;
use crate::workspace::view::{NotebookSource, OpenWarpDriveObjectSettings, Workspace};
use crate::BlocklistAIHistoryModel;
const TRANSCRIPT_PANEL_WIDTH: f32 = 280.0;
/// Builds the OZ runs URL for viewing all cloud runs.
fn build_oz_runs_url() -> String {
format!("{}/runs", ChannelState::oz_root_url())
}
impl Workspace {
pub(super) fn build_wasm_nux_dialog(ctx: &mut ViewContext<Self>) -> ViewHandle<WasmNUXDialog> {
let wasm_nux_dialog = ctx.add_typed_action_view(|_| WasmNUXDialog::new());
ctx.subscribe_to_view(&wasm_nux_dialog, |me, _, event, ctx| match event {
WasmNUXDialogEvent::Close => {
me.show_wasm_nux_dialog = false;
ctx.notify();
}
});
wasm_nux_dialog
}
pub(super) fn build_open_in_warp_button(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<ActionButton> {
ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Open in Warp", PrimaryTheme).on_click(move |ctx| {
// Get the current URL and dispatch action to open it on desktop
if let Some(url) = parse_current_url() {
ctx.dispatch_typed_action(WorkspaceAction::OpenLinkOnDesktop(url));
} else {
log::warn!("Could not get URL for Open in Warp button");
}
})
})
}
pub(super) fn build_view_cloud_runs_button(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<ActionButton> {
let url = build_oz_runs_url();
ctx.add_typed_action_view(|_ctx| {
ActionButton::new("View all cloud runs", SecondaryTheme).on_click(move |ctx| {
ctx.dispatch_typed_action(WorkspaceAction::OpenLink(url.clone()));
})
})
}
pub(super) fn build_transcript_info_button(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<ActionButton> {
ctx.add_typed_action_view(|_ctx| {
ActionButton::new("", NakedTheme)
.with_icon(icons::Icon::Info)
.with_size(ButtonSize::Small)
.on_click(|ctx| {
ctx.dispatch_typed_action(
WorkspaceAction::ToggleConversationTranscriptDetailsPanel,
);
})
})
}
pub(super) fn build_transcript_details_panel(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<ConversationDetailsPanel> {
let panel = ctx.add_typed_action_view(|ctx| {
ConversationDetailsPanel::new(false, TRANSCRIPT_PANEL_WIDTH, ctx)
});
ctx.subscribe_to_view(&panel, |me, _, event, ctx| match event {
ConversationDetailsPanelEvent::Close => {
me.current_workspace_state.is_transcript_details_panel_open = false;
me.transcript_info_button.update(ctx, |button, ctx| {
button.set_active(false, ctx);
});
ctx.notify();
}
ConversationDetailsPanelEvent::OpenPlanNotebook { notebook_uid } => {
me.open_notebook(
&NotebookSource::Existing((*notebook_uid).into()),
&OpenWarpDriveObjectSettings::default(),
ctx,
true,
);
}
});
panel
}
/// Check if we should show the conversation details panel, given the focused terminal view.
/// Returns true for:
/// - Conversation transcript viewers (always)
/// - Shared sessions with an ambient agent task ID, OR an active conversation
pub(super) fn should_show_conversation_details_panel(
focused_terminal_view: &ViewHandle<TerminalView>,
ctx: &AppContext,
) -> bool {
let terminal_view_ref = focused_terminal_view.as_ref(ctx);
let model = terminal_view_ref.model.lock();
// Always show for conversation transcript viewers
if model.is_conversation_transcript_viewer() {
return true;
}
// For shared sessions, show if there's an ambient agent task_id or an active conversation
if model.shared_session_status().is_sharer_or_viewer() {
if model.ambient_agent_task_id().is_some() {
return true;
}
drop(model); // Release lock before accessing BlocklistAIHistoryModel
return BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(focused_terminal_view.id())
.is_some();
}
false
}
/// Renders the transcript details panel for WASM conversation transcript and shared session viewing.
pub(super) fn render_transcript_details_panel(
&self,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let terminal_view = self
.active_tab_pane_group()
.as_ref(app)
.focused_session_view(app)?;
if !Self::should_show_conversation_details_panel(&terminal_view, app) {
return None;
}
Some(self.render_panel(
app,
ChildView::new(&self.transcript_details_panel).finish(),
&PanelPosition::Right,
))
}
pub(super) fn update_transcript_details_panel_data(&mut self, ctx: &mut ViewContext<Self>) {
// Get the focused terminal view
let Some(terminal_view) = self
.active_tab_pane_group()
.as_ref(ctx)
.focused_session_view(ctx)
else {
return;
};
if !Self::should_show_conversation_details_panel(&terminal_view, ctx) {
return;
}
let terminal_view_id = terminal_view.id();
let task_id = terminal_view
.as_ref(ctx)
.ambient_agent_task_id_for_details_panel(ctx);
self.transcript_details_panel.update(ctx, |panel, ctx| {
// If we have an ambient agent task ID, try to populate from task data
if let Some(task_id) = task_id {
let conversations_model_handle = AgentConversationsModel::handle(ctx);
let task = conversations_model_handle.update(ctx, |conversations_model, ctx| {
conversations_model.get_or_async_fetch_task_data(&task_id, ctx)
});
if let Some(task) = task {
let details = ConversationDetailsData::from_task(&task, None, None, ctx);
panel.set_conversation_details(details, ctx);
ctx.notify();
return;
}
}
// Otherwise, populate from conversation
let history_model = BlocklistAIHistoryModel::handle(ctx).as_ref(ctx);
if let Some(conversation) = history_model.active_conversation(terminal_view_id) {
let details = ConversationDetailsData::from_conversation(conversation, ctx);
panel.set_conversation_details(details, ctx);
}
ctx.notify();
});
}
}