first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,74 +1,33 @@
|
||||
use super::two_line_button::{render_two_line_button, TwoLineButtonSpec};
|
||||
use crate::model::{OnboardingAuthState, OnboardingStateEvent, OnboardingStateModel};
|
||||
use crate::slides::{bottom_nav, layout, slide_content};
|
||||
use crate::telemetry::OnboardingEvent;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
|
||||
use super::OnboardingSlide;
|
||||
use crate::visuals::agent_visual;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::{
|
||||
appearance::Appearance,
|
||||
theme::{color::internal_colors, Fill},
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
AnchorPair, Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, Dismiss, Empty, Flex, FormattedTextElement, Hoverable,
|
||||
Icon as WarpUiIcon, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
|
||||
OffsetType, ParentElement, ParentOffsetBounds, PositioningAxis, Radius, SavePosition,
|
||||
ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Stack, Text, XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
fonts::Properties,
|
||||
fonts::Weight,
|
||||
keymap::Keystroke,
|
||||
platform::Cursor,
|
||||
scene::DropShadow,
|
||||
text_layout::TextAlignment,
|
||||
ui_components::components::{UiComponent as _, UiComponentStyles},
|
||||
AppContext, Element, Entity, Gradient, SingletonEntity as _, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use ui_components::{button, Component as _, Options as _};
|
||||
|
||||
use ai::LLMId;
|
||||
use galaxy_core::ui::icons::Icon;
|
||||
use pathfinder_color::ColorU;
|
||||
use ui_components::button::State as ButtonState;
|
||||
use ui_components::{button, Component as _, Options as _};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui_core::elements::{
|
||||
AnchorPair, Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, Dismiss, Empty, Flex, FormattedTextElement, Hoverable,
|
||||
Icon as WarpUiIcon, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
|
||||
OffsetType, ParentElement, ParentOffsetBounds, PositioningAxis, Radius, SavePosition,
|
||||
ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Stack, Text, XAxisAnchor, YAxisAnchor,
|
||||
};
|
||||
use galaxyui_core::fonts::{Properties, Weight};
|
||||
use galaxyui_core::keymap::Keystroke;
|
||||
use galaxyui_core::platform::Cursor;
|
||||
use galaxyui_core::scene::DropShadow;
|
||||
use galaxyui_core::text_layout::TextAlignment;
|
||||
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
|
||||
use galaxyui_core::{
|
||||
AppContext, Element, Entity, SingletonEntity as _, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
/// high-contrast "inverted" fill (foreground color)
|
||||
struct UpgradeButtonTheme;
|
||||
|
||||
impl button::Theme for UpgradeButtonTheme {
|
||||
fn background(
|
||||
&self,
|
||||
button_state: ButtonState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<galaxy_core::ui::theme::Fill> {
|
||||
use galaxy_core::ui::color::blend::Blend;
|
||||
let theme = appearance.theme();
|
||||
let base = theme.foreground();
|
||||
match button_state {
|
||||
ButtonState::Default => Some(base),
|
||||
// Blend a little of the theme background back in to dim on hover /
|
||||
// press. Opacities are relative to the foreground fill.
|
||||
ButtonState::Hovered => Some(base.blend(&theme.background().with_opacity(15))),
|
||||
ButtonState::Pressed => Some(base.blend(&theme.background().with_opacity(30))),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
background: Option<galaxy_core::ui::theme::Fill>,
|
||||
appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
let bg = background
|
||||
.unwrap_or_else(|| appearance.theme().background())
|
||||
.into_solid();
|
||||
appearance.theme().font_color(bg).into()
|
||||
}
|
||||
}
|
||||
use super::two_line_button::{render_two_line_button, TwoLineButtonSpec};
|
||||
use super::OnboardingSlide;
|
||||
use crate::model::{OnboardingStateEvent, OnboardingStateModel};
|
||||
use crate::slides::{bottom_nav, layout, slide_content};
|
||||
use crate::visuals::agent_visual;
|
||||
|
||||
/// Information about a model displayed on the onboarding slide.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -76,7 +35,6 @@ pub struct OnboardingModelInfo {
|
||||
pub id: LLMId,
|
||||
pub title: String,
|
||||
pub icon: Icon,
|
||||
pub requires_upgrade: bool,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
@@ -139,20 +97,10 @@ pub enum AgentSlideAction {
|
||||
ToggleDisableOz,
|
||||
BackClicked,
|
||||
NextClicked,
|
||||
UpgradeClicked,
|
||||
CopyUpgradeUrlClicked,
|
||||
PasteAuthTokenFromClipboardClicked,
|
||||
DismissPlanActivatedToast,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AgentSlideEvent {
|
||||
CopyUpgradeUrlRequested,
|
||||
PasteAuthTokenFromClipboardRequested,
|
||||
}
|
||||
|
||||
pub struct AgentSlide {
|
||||
onboarding_state: galaxyui::ModelHandle<OnboardingStateModel>,
|
||||
onboarding_state: galaxyui_core::ModelHandle<OnboardingStateModel>,
|
||||
|
||||
/// Mouse state handles for each model row.
|
||||
model_mouse_states: Vec<MouseStateHandle>,
|
||||
@@ -164,25 +112,14 @@ pub struct AgentSlide {
|
||||
autonomy_partial_mouse_state: MouseStateHandle,
|
||||
autonomy_none_mouse_state: MouseStateHandle,
|
||||
|
||||
disable_oz_mouse: MouseStateHandle,
|
||||
|
||||
back_button: button::Button,
|
||||
next_button: button::Button,
|
||||
upgrade_button: button::Button,
|
||||
scroll_state: ClippedScrollStateHandle,
|
||||
dropdown_scroll_state: ClippedScrollStateHandle,
|
||||
is_model_list_expanded: bool,
|
||||
highlighted_model_id: Option<LLMId>,
|
||||
show_auth_prompt_bar: bool,
|
||||
copy_url_mouse_state: MouseStateHandle,
|
||||
paste_token_mouse_state: MouseStateHandle,
|
||||
show_plan_activated_toast: bool,
|
||||
last_auth_state: OnboardingAuthState,
|
||||
plan_activated_close_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
const PLAN_ACTIVATED_TOAST_DURATION: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Produces the `SavePosition` id for the model row at `index` in the
|
||||
/// dropdown list. Used by `scroll_to_position` to scroll a specific row into
|
||||
/// view as the keyboard highlight moves.
|
||||
@@ -190,18 +127,9 @@ fn model_row_position_id(index: usize) -> String {
|
||||
format!("agent_slide_model_row_{index}")
|
||||
}
|
||||
|
||||
/// Returns the slide's view of the model list: free-tier before premium,
|
||||
/// with server order preserved within each tier. The slide owns this sort so
|
||||
/// state storage can stay in server order.
|
||||
fn sorted_models(models: &[OnboardingModelInfo]) -> Vec<OnboardingModelInfo> {
|
||||
let (free, premium): (Vec<_>, Vec<_>) =
|
||||
models.iter().cloned().partition(|m| !m.requires_upgrade);
|
||||
free.into_iter().chain(premium).collect()
|
||||
}
|
||||
|
||||
impl AgentSlide {
|
||||
pub(crate) fn new(
|
||||
onboarding_state: galaxyui::ModelHandle<OnboardingStateModel>,
|
||||
onboarding_state: galaxyui_core::ModelHandle<OnboardingStateModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let model_count = onboarding_state.as_ref(ctx).models().len();
|
||||
@@ -209,8 +137,6 @@ impl AgentSlide {
|
||||
.map(|_| MouseStateHandle::default())
|
||||
.collect();
|
||||
|
||||
let initial_auth_state = onboarding_state.as_ref(ctx).auth_state();
|
||||
|
||||
ctx.subscribe_to_model(&onboarding_state, |me, model, event, ctx| {
|
||||
match event {
|
||||
OnboardingStateEvent::ModelsUpdated => {
|
||||
@@ -226,30 +152,12 @@ impl AgentSlide {
|
||||
let model_count = state.models().len();
|
||||
me.ensure_mouse_states_for_models(model_count, ctx);
|
||||
}
|
||||
OnboardingStateEvent::AuthStateChanged => {
|
||||
let new_state = model.as_ref(ctx).auth_state();
|
||||
if new_state == OnboardingAuthState::PayingUser
|
||||
&& me.last_auth_state != OnboardingAuthState::PayingUser
|
||||
{
|
||||
me.show_plan_activated_toast = true;
|
||||
// Auto-dismiss after the configured duration.
|
||||
let _ = ctx.spawn(
|
||||
galaxyui::r#async::Timer::after(PLAN_ACTIVATED_TOAST_DURATION),
|
||||
|me: &mut Self, _, ctx| {
|
||||
if me.show_plan_activated_toast {
|
||||
me.show_plan_activated_toast = false;
|
||||
ctx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
me.last_auth_state = new_state;
|
||||
ctx.notify();
|
||||
}
|
||||
OnboardingStateEvent::SelectedSlideChanged
|
||||
OnboardingStateEvent::AuthStateChanged
|
||||
| OnboardingStateEvent::SelectedSlideChanged
|
||||
| OnboardingStateEvent::IntentionChanged
|
||||
| OnboardingStateEvent::Completed
|
||||
| OnboardingStateEvent::UpgradeRequested => {}
|
||||
| OnboardingStateEvent::UpgradeRequested
|
||||
| OnboardingStateEvent::NoAiConfirmationChanged => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -260,20 +168,12 @@ impl AgentSlide {
|
||||
autonomy_full_mouse_state: MouseStateHandle::default(),
|
||||
autonomy_partial_mouse_state: MouseStateHandle::default(),
|
||||
autonomy_none_mouse_state: MouseStateHandle::default(),
|
||||
disable_oz_mouse: MouseStateHandle::default(),
|
||||
back_button: button::Button::default(),
|
||||
next_button: button::Button::default(),
|
||||
upgrade_button: button::Button::default(),
|
||||
scroll_state: ClippedScrollStateHandle::new(),
|
||||
dropdown_scroll_state: ClippedScrollStateHandle::new(),
|
||||
is_model_list_expanded: false,
|
||||
highlighted_model_id: None,
|
||||
show_auth_prompt_bar: false,
|
||||
copy_url_mouse_state: MouseStateHandle::default(),
|
||||
paste_token_mouse_state: MouseStateHandle::default(),
|
||||
show_plan_activated_toast: false,
|
||||
last_auth_state: initial_auth_state,
|
||||
plan_activated_close_mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +208,7 @@ impl AgentSlide {
|
||||
// state is a floating overlay (built in `View::render`) that sits *on top
|
||||
// of* this content, so the underlying layout never shifts between the two
|
||||
// states. That keeps the header + picker chip pinned in place.
|
||||
let bottom_nav = self.render_bottom_nav(appearance);
|
||||
let bottom_nav = self.render_bottom_nav(appearance, app);
|
||||
slide_content::onboarding_slide_content(
|
||||
vec![
|
||||
self.render_header(appearance),
|
||||
@@ -333,7 +233,7 @@ impl AgentSlide {
|
||||
.finish();
|
||||
|
||||
let subtitle = FormattedTextElement::from_str(
|
||||
"Select your in-app agent's defaults.",
|
||||
"Select your Warp Agent's defaults.",
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
)
|
||||
@@ -389,19 +289,10 @@ impl AgentSlide {
|
||||
upper_col.finish()
|
||||
};
|
||||
|
||||
let mut col = Flex::column()
|
||||
let col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(upper_sections);
|
||||
|
||||
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
let disable_oz_section = self.render_disable_oz_section(appearance, settings);
|
||||
col = col.with_child(
|
||||
Container::new(disable_oz_section)
|
||||
.with_margin_top(24.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
Container::new(col.finish()).with_margin_top(40.).finish()
|
||||
}
|
||||
|
||||
@@ -458,31 +349,15 @@ impl AgentSlide {
|
||||
);
|
||||
}
|
||||
|
||||
let has_disabled = self
|
||||
.onboarding_state
|
||||
.as_ref(app)
|
||||
.models()
|
||||
.iter()
|
||||
.any(|m| m.requires_upgrade);
|
||||
|
||||
let mut col = Flex::column()
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(header)
|
||||
.with_child(
|
||||
Container::new(chip_stack.finish())
|
||||
.with_margin_top(12.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if has_disabled {
|
||||
col = col.with_child(
|
||||
Container::new(self.render_upgrade_banner(appearance))
|
||||
.with_margin_top(12.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
col.finish()
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders the single-row collapsed picker button: provider icon, selected title,
|
||||
@@ -595,9 +470,8 @@ impl AgentSlide {
|
||||
}
|
||||
|
||||
/// Renders the vertical list of model rows shown inside the floating dropdown
|
||||
/// overlay. Each row: provider icon + title on the left, pill on the right
|
||||
/// (Premium for paywalled rows). Disabled rows are rendered dimmed and are
|
||||
/// not clickable or hover-selectable.
|
||||
/// overlay. Each row shows a provider icon + title on the left, with a
|
||||
/// "Recommended" pill on the right for the default model.
|
||||
fn render_model_list_rows(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
@@ -609,7 +483,7 @@ impl AgentSlide {
|
||||
let state = self.onboarding_state.as_ref(app);
|
||||
let highlighted_id = self.highlighted_model_id.clone();
|
||||
let selected_id = state.agent_settings().selected_model_id.clone();
|
||||
let models = sorted_models(state.models());
|
||||
let models = state.models();
|
||||
|
||||
let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
@@ -695,18 +569,11 @@ impl AgentSlide {
|
||||
let background_for_text = theme.background().into_solid();
|
||||
let ui_font_family = appearance.ui_font_family();
|
||||
|
||||
let is_disabled = model.requires_upgrade;
|
||||
|
||||
let title_color: ColorU = if is_disabled {
|
||||
internal_colors::text_disabled(theme, background_for_text)
|
||||
} else {
|
||||
internal_colors::text_main(theme, background_for_text)
|
||||
};
|
||||
let title_color: ColorU = internal_colors::text_main(theme, background_for_text);
|
||||
|
||||
let row_id = model.id.clone();
|
||||
let title = model.title.clone();
|
||||
let icon = model.icon;
|
||||
let requires_upgrade = model.requires_upgrade;
|
||||
let is_default = model.is_default;
|
||||
|
||||
let hoverable_body = Hoverable::new(mouse_state, move |_| {
|
||||
@@ -730,10 +597,7 @@ impl AgentSlide {
|
||||
.with_child(Container::new(title_el).with_margin_left(8.).finish())
|
||||
.finish();
|
||||
|
||||
// Trailing pills: "Recommended" on the server-designated default
|
||||
// model, "Premium" on paywalled rows. In practice a single row is
|
||||
// at most one of these, but both can be shown side-by-side if the
|
||||
// default is also premium for any reason.
|
||||
// "Recommended" pill on the server-designated default model.
|
||||
let make_pill = |label: &'static str| -> Box<dyn Element> {
|
||||
let badge = Text::new(label.to_string(), ui_font_family, 11.0)
|
||||
.with_color(internal_colors::text_sub(theme, background_for_text))
|
||||
@@ -755,8 +619,6 @@ impl AgentSlide {
|
||||
|
||||
let trailing: Box<dyn Element> = if is_default {
|
||||
make_pill("Recommended")
|
||||
} else if requires_upgrade {
|
||||
make_pill("Premium")
|
||||
} else {
|
||||
Empty::new().finish()
|
||||
};
|
||||
@@ -769,7 +631,7 @@ impl AgentSlide {
|
||||
.with_child(trailing)
|
||||
.finish();
|
||||
|
||||
let background = if is_highlighted && !is_disabled {
|
||||
let background = if is_highlighted {
|
||||
Some(Fill::Solid(internal_colors::neutral_2(theme)))
|
||||
} else {
|
||||
None
|
||||
@@ -787,26 +649,19 @@ impl AgentSlide {
|
||||
.finish()
|
||||
});
|
||||
|
||||
if is_disabled {
|
||||
// Disabled rows: no click, no hover-updates-highlight, muted.
|
||||
hoverable_body.finish()
|
||||
} else {
|
||||
let click_id = row_id.clone();
|
||||
let hover_id = row_id;
|
||||
hoverable_body
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_hover(move |is_hovered, ctx, _, _| {
|
||||
if is_hovered {
|
||||
ctx.dispatch_typed_action(AgentSlideAction::HighlightModel(
|
||||
hover_id.clone(),
|
||||
));
|
||||
}
|
||||
})
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AgentSlideAction::SelectModel(click_id.clone()));
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
let click_id = row_id.clone();
|
||||
let hover_id = row_id;
|
||||
hoverable_body
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_hover(move |is_hovered, ctx, _, _| {
|
||||
if is_hovered {
|
||||
ctx.dispatch_typed_action(AgentSlideAction::HighlightModel(hover_id.clone()));
|
||||
}
|
||||
})
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AgentSlideAction::SelectModel(click_id.clone()));
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_autonomy_workspace_enforced(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
@@ -881,19 +736,19 @@ impl AgentSlide {
|
||||
(
|
||||
AgentAutonomy::Full,
|
||||
"Full",
|
||||
"Runs commands, writes code, and reads files without asking.",
|
||||
"Warp Agent runs commands, writes code, and reads files without asking.",
|
||||
self.autonomy_full_mouse_state.clone(),
|
||||
),
|
||||
(
|
||||
AgentAutonomy::Partial,
|
||||
"Partial",
|
||||
"Can plan, read files, and execute low-risk commands. Asks before making any changes or executing sensitive commands.",
|
||||
"Warp Agent can plan, read files, and execute low-risk commands. Asks before making any changes or executing sensitive commands.",
|
||||
self.autonomy_partial_mouse_state.clone(),
|
||||
),
|
||||
(
|
||||
AgentAutonomy::None,
|
||||
"None",
|
||||
"Takes no actions without your approval.",
|
||||
"Warp Agent takes no actions without your approval.",
|
||||
self.autonomy_none_mouse_state.clone(),
|
||||
),
|
||||
];
|
||||
@@ -939,39 +794,7 @@ impl AgentSlide {
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_disable_oz_section(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
settings: &AgentDevelopmentSettings,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let background_for_text = theme.background().into_solid();
|
||||
|
||||
let checkbox = appearance
|
||||
.ui_builder()
|
||||
.checkbox(self.disable_oz_mouse.clone(), Some(12.))
|
||||
.check(settings.disable_oz)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(AgentSlideAction::ToggleDisableOz))
|
||||
.finish();
|
||||
|
||||
let label = Text::new("Disable Warp Agent", appearance.ui_font_family(), 14.0)
|
||||
.with_color(internal_colors::text_sub(theme, background_for_text))
|
||||
.with_style(Properties {
|
||||
weight: Weight::Normal,
|
||||
..Default::default()
|
||||
})
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(checkbox)
|
||||
.with_child(Container::new(label).with_margin_left(8.).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_bottom_nav(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
fn render_bottom_nav(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let back_button = self.back_button.render(
|
||||
appearance,
|
||||
button::Params {
|
||||
@@ -1002,13 +825,7 @@ impl AgentSlide {
|
||||
},
|
||||
);
|
||||
|
||||
let step_index = 2;
|
||||
let step_count =
|
||||
if galaxy_core::features::FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
5
|
||||
} else {
|
||||
4
|
||||
};
|
||||
let (step_index, step_count) = self.onboarding_state.as_ref(app).progress();
|
||||
bottom_nav::onboarding_bottom_nav(
|
||||
appearance,
|
||||
step_index,
|
||||
@@ -1018,98 +835,6 @@ impl AgentSlide {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_upgrade_banner(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
// Diagonal magenta → yellow gradient (top-left to bottom-right). Chosen
|
||||
// to match the "premium" glow styling in the Figma mocks.
|
||||
const GRADIENT_START_MAGENTA: ColorU = ColorU {
|
||||
r: 0xE2,
|
||||
g: 0x48,
|
||||
b: 0xBC,
|
||||
a: 0xFF,
|
||||
};
|
||||
const GRADIENT_END_YELLOW: ColorU = ColorU {
|
||||
r: 0xF5,
|
||||
g: 0xB7,
|
||||
b: 0x00,
|
||||
a: 0xFF,
|
||||
};
|
||||
|
||||
let theme = appearance.theme();
|
||||
let background_for_text = theme.background().into_solid();
|
||||
let ui_font_family = appearance.ui_font_family();
|
||||
|
||||
// Primary "heading" line: bolder, full-contrast.
|
||||
let title = Text::new(
|
||||
"Upgrade for access to premium models.",
|
||||
ui_font_family,
|
||||
13.0,
|
||||
)
|
||||
.with_color(internal_colors::text_main(theme, background_for_text))
|
||||
.with_style(Properties {
|
||||
weight: Weight::Medium,
|
||||
..Default::default()
|
||||
})
|
||||
.with_line_height_ratio(1.2)
|
||||
.finish();
|
||||
|
||||
// Secondary subtext: muted, normal weight.
|
||||
let subtitle = Text::new(
|
||||
"State-of-the-art models require paid plans.",
|
||||
ui_font_family,
|
||||
12.0,
|
||||
)
|
||||
.with_color(internal_colors::text_sub(theme, background_for_text))
|
||||
.with_style(Properties {
|
||||
weight: Weight::Normal,
|
||||
..Default::default()
|
||||
})
|
||||
.with_line_height_ratio(1.2)
|
||||
.finish();
|
||||
|
||||
let text_col = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(subtitle).with_margin_top(4.).finish())
|
||||
.finish();
|
||||
|
||||
let upgrade_button = self.upgrade_button.render(
|
||||
appearance,
|
||||
button::Params {
|
||||
content: button::Content::Label("Upgrade".into()),
|
||||
theme: &UpgradeButtonTheme,
|
||||
options: button::Options {
|
||||
size: button::Size::Small,
|
||||
on_click: Some(Box::new(|ctx, _app, _pos| {
|
||||
ctx.dispatch_typed_action(AgentSlideAction::UpgradeClicked);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(text_col)
|
||||
.with_child(upgrade_button)
|
||||
.finish();
|
||||
|
||||
Container::new(row)
|
||||
.with_uniform_padding(12.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_border(Border::all(1.).with_border_gradient(
|
||||
vec2f(0., 0.),
|
||||
vec2f(1., 1.),
|
||||
Gradient {
|
||||
start: GRADIENT_START_MAGENTA,
|
||||
end: GRADIENT_END_YELLOW,
|
||||
},
|
||||
))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_visual(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
@@ -1138,187 +863,10 @@ impl AgentSlide {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-width bar pinned below the slide's two-column layout. Shown after
|
||||
/// the user clicks the Upgrade button, so they can fall back to copying
|
||||
/// the upgrade URL (or pasting the returned auth token) if the browser
|
||||
/// didn't launch automatically.
|
||||
fn render_auth_prompt_bar(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
const BAR_HEIGHT: f32 = 40.;
|
||||
const ICON_SIZE: f32 = 14.;
|
||||
const FONT_SIZE: f32 = 12.;
|
||||
|
||||
let theme = appearance.theme();
|
||||
let bar_bg = theme.surface_1();
|
||||
let bar_bg_solid = bar_bg.into_solid();
|
||||
let text_color = internal_colors::text_sub(theme, bar_bg_solid);
|
||||
let ui_builder = appearance.ui_builder();
|
||||
|
||||
let text_styles = UiComponentStyles {
|
||||
font_color: Some(text_color),
|
||||
font_size: Some(FONT_SIZE),
|
||||
..Default::default()
|
||||
};
|
||||
let link_styles = UiComponentStyles {
|
||||
font_size: Some(FONT_SIZE),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let icon = ConstrainedBox::new(Box::new(
|
||||
Icon::AlertCircle.to_galaxyui_icon(Fill::Solid(text_color)),
|
||||
))
|
||||
.with_width(ICON_SIZE)
|
||||
.with_height(ICON_SIZE)
|
||||
.finish();
|
||||
|
||||
let copy_url_link = ui_builder
|
||||
.link(
|
||||
"copy the URL".into(),
|
||||
None,
|
||||
Some(Box::new(|ctx| {
|
||||
ctx.dispatch_typed_action(AgentSlideAction::CopyUpgradeUrlClicked);
|
||||
})),
|
||||
self.copy_url_mouse_state.clone(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_style(link_styles)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let paste_token_link = ui_builder
|
||||
.link(
|
||||
"Click here".into(),
|
||||
None,
|
||||
Some(Box::new(|ctx| {
|
||||
ctx.dispatch_typed_action(AgentSlideAction::PasteAuthTokenFromClipboardClicked);
|
||||
})),
|
||||
self.paste_token_mouse_state.clone(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_style(link_styles)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let text_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(icon)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.span("If your browser hasn't launched, ")
|
||||
.with_style(text_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(copy_url_link)
|
||||
.with_child(
|
||||
ui_builder
|
||||
.span(" and open the page manually. ")
|
||||
.with_style(text_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_child(paste_token_link)
|
||||
.with_child(
|
||||
ui_builder
|
||||
.span(" to paste your token from the browser.")
|
||||
.with_style(text_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(text_row)
|
||||
.finish();
|
||||
|
||||
ConstrainedBox::new(
|
||||
Container::new(row)
|
||||
.with_background(bar_bg)
|
||||
.with_border(Border::top(1.).with_border_color(internal_colors::neutral_4(theme)))
|
||||
.with_horizontal_padding(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_min_height(BAR_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Green success pill shown when the user's `OnboardingAuthState`
|
||||
/// transitions into `PayingUser`. Auto-dismisses after
|
||||
/// `PLAN_ACTIVATED_TOAST_DURATION`; also dismissable via the close X.
|
||||
fn render_plan_activated_toast(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
const TOAST_MIN_HEIGHT: f32 = 40.;
|
||||
const ICON_SIZE: f32 = 14.;
|
||||
const CLOSE_SIZE: f32 = 16.;
|
||||
const FONT_SIZE: f32 = 12.;
|
||||
|
||||
let theme = appearance.theme();
|
||||
let toast_bg: Fill = theme.ansi_fg_green().into();
|
||||
let text_color: ColorU = theme.font_color(toast_bg.into_solid()).into();
|
||||
let ui_builder = appearance.ui_builder();
|
||||
|
||||
let check_icon = ConstrainedBox::new(Box::new(
|
||||
Icon::CheckSkinny.to_galaxyui_icon(Fill::Solid(text_color)),
|
||||
))
|
||||
.with_width(ICON_SIZE)
|
||||
.with_height(ICON_SIZE)
|
||||
.finish();
|
||||
|
||||
let text = ui_builder
|
||||
.span("Plan successfully activated. All premium models are available.")
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(text_color),
|
||||
font_size: Some(FONT_SIZE),
|
||||
font_weight: Some(Weight::Medium),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let close_button = ui_builder
|
||||
.close_button(CLOSE_SIZE, self.plan_activated_close_mouse_state.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(text_color),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AgentSlideAction::DismissPlanActivatedToast);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let left = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(check_icon)
|
||||
.with_child(Container::new(text).with_margin_left(8.).finish())
|
||||
.finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(left)
|
||||
.with_child(close_button)
|
||||
.finish();
|
||||
|
||||
ConstrainedBox::new(
|
||||
Container::new(row)
|
||||
.with_background(toast_bg)
|
||||
.with_horizontal_padding(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_min_height(TOAST_MIN_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AgentSlide {
|
||||
type Event = AgentSlideEvent;
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for AgentSlide {
|
||||
@@ -1334,39 +882,10 @@ impl View for AgentSlide {
|
||||
// The floating dropdown overlay is built inside `render_model_section`
|
||||
// so it inherits the column width naturally. Here we only need the
|
||||
// base two-column layout.
|
||||
let slide = layout::static_left(
|
||||
layout::static_left(
|
||||
|| self.render_content(appearance, settings, workspace_enforces_autonomy, app),
|
||||
|| self.render_visual(appearance, app),
|
||||
);
|
||||
|
||||
// Upgrade-prompt bar: shown after the user clicks Upgrade, as long as
|
||||
// they aren't yet on a paid plan. Overlays the bottom of the slide
|
||||
// (doesn't bump slide content up) so the slide layout stays stable
|
||||
// whether or not the bar is visible.
|
||||
//
|
||||
// The plan-activated success toast supersedes the bar (and any other
|
||||
// bottom overlay) while it's visible.
|
||||
let auth_state = self.onboarding_state.as_ref(app).auth_state();
|
||||
let show_bar =
|
||||
self.show_auth_prompt_bar && !matches!(auth_state, OnboardingAuthState::PayingUser);
|
||||
if !show_bar && !self.show_plan_activated_toast {
|
||||
return slide;
|
||||
}
|
||||
|
||||
let bottom_overlay = if self.show_plan_activated_toast {
|
||||
self.render_plan_activated_toast(appearance)
|
||||
} else {
|
||||
self.render_auth_prompt_bar(appearance)
|
||||
};
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(slide);
|
||||
stack.add_child(
|
||||
galaxyui::elements::Align::new(bottom_overlay)
|
||||
.bottom_center()
|
||||
.finish(),
|
||||
);
|
||||
stack.finish()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1388,10 +907,7 @@ impl AgentSlide {
|
||||
// starts on the selected row.
|
||||
let state = self.onboarding_state.as_ref(ctx);
|
||||
let selected_id = state.agent_settings().selected_model_id.clone();
|
||||
if let Some(index) = sorted_models(state.models())
|
||||
.iter()
|
||||
.position(|m| m.id == selected_id)
|
||||
{
|
||||
if let Some(index) = state.models().iter().position(|m| m.id == selected_id) {
|
||||
self.dropdown_scroll_state.scroll_to_position(ScrollTarget {
|
||||
position_id: model_row_position_id(index),
|
||||
mode: ScrollToPositionMode::FullyIntoView,
|
||||
@@ -1402,55 +918,33 @@ impl AgentSlide {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Finds the next enabled model index in the given direction, wrapping
|
||||
/// around. Indices are into the slide's sorted view of the model list.
|
||||
/// Returns `None` if all models are paywalled.
|
||||
fn next_enabled_model_index(
|
||||
&self,
|
||||
start: usize,
|
||||
forward: bool,
|
||||
ctx: &AppContext,
|
||||
) -> Option<usize> {
|
||||
let models = sorted_models(self.onboarding_state.as_ref(ctx).models());
|
||||
let count = models.len();
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
for offset in 1..=count {
|
||||
let idx = if forward {
|
||||
(start + offset) % count
|
||||
} else {
|
||||
(start + count - offset) % count
|
||||
};
|
||||
if !models[idx].requires_upgrade {
|
||||
return Some(idx);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Advances the highlight cursor to the next/previous enabled model, wrapping.
|
||||
/// The origin of the walk is the currently-highlighted id (if any), else the
|
||||
/// Advances the highlight cursor to the next/previous model, wrapping. The
|
||||
/// origin of the walk is the currently-highlighted id (if any), else the
|
||||
/// currently-selected id. Also scrolls the dropdown so the newly-highlighted
|
||||
/// row stays visible — same `SavePosition` + `scroll_to_position` pattern
|
||||
/// used by `VerticalTabsPanelState::scroll_to_tab`.
|
||||
fn advance_highlighted_model(&mut self, forward: bool, ctx: &mut ViewContext<Self>) {
|
||||
let state = self.onboarding_state.as_ref(ctx);
|
||||
let sorted = sorted_models(state.models());
|
||||
let selected_id = state.agent_settings().selected_model_id.clone();
|
||||
let (model_ids, selected_id) = {
|
||||
let state = self.onboarding_state.as_ref(ctx);
|
||||
let ids: Vec<LLMId> = state.models().iter().map(|m| m.id.clone()).collect();
|
||||
(ids, state.agent_settings().selected_model_id.clone())
|
||||
};
|
||||
let count = model_ids.len();
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
let start_index = self
|
||||
.highlighted_model_id
|
||||
.as_ref()
|
||||
.and_then(|id| sorted.iter().position(|m| &m.id == id))
|
||||
.or_else(|| sorted.iter().position(|m| m.id == selected_id))
|
||||
.and_then(|id| model_ids.iter().position(|m| m == id))
|
||||
.or_else(|| model_ids.iter().position(|m| *m == selected_id))
|
||||
.unwrap_or(0);
|
||||
let Some(next_index) = self.next_enabled_model_index(start_index, forward, ctx) else {
|
||||
return;
|
||||
let next_index = if forward {
|
||||
(start_index + 1) % count
|
||||
} else {
|
||||
(start_index + count - 1) % count
|
||||
};
|
||||
let Some(next_id) = sorted.get(next_index).map(|m| m.id.clone()) else {
|
||||
return;
|
||||
};
|
||||
self.highlighted_model_id = Some(next_id);
|
||||
self.highlighted_model_id = Some(model_ids[next_index].clone());
|
||||
// Scroll the dropdown so the new highlight is visible. `FullyIntoView`
|
||||
// is a no-op when the row is already fully in view, otherwise it
|
||||
// scrolls the minimum amount to show it.
|
||||
@@ -1532,16 +1026,7 @@ impl OnboardingSlide for AgentSlide {
|
||||
// and collapses the list. Does NOT advance to the next slide.
|
||||
if self.is_model_list_expanded {
|
||||
if let Some(id) = self.highlighted_model_id.clone() {
|
||||
// Only select if the highlighted row is still enabled.
|
||||
let enabled = self
|
||||
.onboarding_state
|
||||
.as_ref(ctx)
|
||||
.models()
|
||||
.iter()
|
||||
.any(|m| m.id == id && !m.requires_upgrade);
|
||||
if enabled {
|
||||
self.select_model(id, ctx);
|
||||
}
|
||||
self.select_model(id, ctx);
|
||||
}
|
||||
self.set_model_list_expanded(false, ctx);
|
||||
return;
|
||||
@@ -1578,16 +1063,7 @@ impl TypedActionView for AgentSlide {
|
||||
self.set_model_list_expanded(!self.is_model_list_expanded, ctx);
|
||||
}
|
||||
AgentSlideAction::HighlightModel(model_id) => {
|
||||
// Only update if the id corresponds to an enabled row. Callers
|
||||
// (hover handlers) already filter this out, but we defend against
|
||||
// stale actions fired while the list was re-rendering.
|
||||
let enabled = self
|
||||
.onboarding_state
|
||||
.as_ref(ctx)
|
||||
.models()
|
||||
.iter()
|
||||
.any(|m| m.id == *model_id && !m.requires_upgrade);
|
||||
if enabled && self.highlighted_model_id.as_ref() != Some(model_id) {
|
||||
if self.highlighted_model_id.as_ref() != Some(model_id) {
|
||||
self.highlighted_model_id = Some(model_id.clone());
|
||||
ctx.notify();
|
||||
}
|
||||
@@ -1616,29 +1092,6 @@ impl TypedActionView for AgentSlide {
|
||||
AgentSlideAction::NextClicked => {
|
||||
self.next(ctx);
|
||||
}
|
||||
AgentSlideAction::UpgradeClicked => {
|
||||
send_telemetry_from_ctx!(OnboardingEvent::AgentSlideUpgradeClicked, ctx);
|
||||
if !matches!(
|
||||
self.onboarding_state.as_ref(ctx).auth_state(),
|
||||
OnboardingAuthState::PayingUser,
|
||||
) {
|
||||
self.show_auth_prompt_bar = true;
|
||||
ctx.notify();
|
||||
}
|
||||
self.onboarding_state.update(ctx, |state, ctx| {
|
||||
state.request_upgrade(ctx);
|
||||
});
|
||||
}
|
||||
AgentSlideAction::CopyUpgradeUrlClicked => {
|
||||
ctx.emit(AgentSlideEvent::CopyUpgradeUrlRequested);
|
||||
}
|
||||
AgentSlideAction::PasteAuthTokenFromClipboardClicked => {
|
||||
ctx.emit(AgentSlideEvent::PasteAuthTokenFromClipboardRequested);
|
||||
}
|
||||
AgentSlideAction::DismissPlanActivatedToast => {
|
||||
self.show_plan_activated_toast = false;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user