Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,730 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use markdown_parser::{
|
||||
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
|
||||
};
|
||||
use warpui::elements::{
|
||||
Align, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
|
||||
FormattedTextElement, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
|
||||
Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::geometry::vector::Vector2F;
|
||||
use warpui::keymap::{FixedBinding, Keystroke};
|
||||
use warpui::platform::file_picker::{FilePickerConfiguration, FilePickerError};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, EventContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::settings::AISettings;
|
||||
use crate::tab_configs::session_config::{is_git_repo, SessionConfigSelection, SessionType};
|
||||
use crate::tab_configs::session_config_rendering;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource,
|
||||
};
|
||||
use crate::view_components::callout_bubble::{
|
||||
callout_body_color, callout_checkbox, callout_label_color, callout_title_color,
|
||||
render_callout_bubble, CalloutArrowDirection, CalloutArrowPosition, CalloutBubbleConfig,
|
||||
};
|
||||
use crate::workspace::tab_settings::TabSettings;
|
||||
|
||||
use settings::Setting;
|
||||
|
||||
use super::tab_config_step;
|
||||
use super::welcome_banner;
|
||||
|
||||
const CALLOUT_WIDTH: f32 = 480.;
|
||||
|
||||
struct HoaPrimaryButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for HoaPrimaryButtonTheme {
|
||||
fn background(&self, hovered: bool, _appearance: &Appearance) -> Option<Fill> {
|
||||
Some(PhenomenonStyle::primary_button_background(hovered))
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
_appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
PhenomenonStyle::primary_button_text()
|
||||
}
|
||||
}
|
||||
struct HoaWelcomeModalButtonTheme;
|
||||
struct HoaWelcomeModalCloseButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for HoaWelcomeModalButtonTheme {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionButtonTheme for HoaWelcomeModalCloseButtonTheme {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// The 4 sequential steps in the HOA onboarding flow.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HoaOnboardingStep {
|
||||
WelcomeBanner,
|
||||
VerticalTabsCallout,
|
||||
AgentInboxCallout,
|
||||
TabConfig,
|
||||
}
|
||||
|
||||
impl HoaOnboardingStep {
|
||||
fn index(&self) -> usize {
|
||||
match self {
|
||||
HoaOnboardingStep::WelcomeBanner => 0,
|
||||
HoaOnboardingStep::VerticalTabsCallout => 0,
|
||||
HoaOnboardingStep::AgentInboxCallout => 1,
|
||||
HoaOnboardingStep::TabConfig => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn total_dots() -> usize {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(app: &mut warpui::AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"enter",
|
||||
HoaOnboardingAction::EnterPressed,
|
||||
id!(HoaOnboardingFlow::ui_name()),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum HoaOnboardingAction {
|
||||
EnterPressed,
|
||||
AdvanceFromWelcome,
|
||||
AdvanceFromVerticalTabs,
|
||||
AdvanceFromInbox,
|
||||
ToggleHorizontalTabs,
|
||||
SelectSessionType(usize),
|
||||
OpenDirectoryPicker,
|
||||
DirectorySelected(Result<String, FilePickerError>),
|
||||
ToggleWorktree,
|
||||
ToggleAutogenerateWorktreeBranchName,
|
||||
Finish,
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
pub enum HoaOnboardingFlowEvent {
|
||||
Completed(Option<SessionConfigSelection>),
|
||||
Dismissed,
|
||||
StepChanged,
|
||||
/// Emitted just before toggling vertical/horizontal tabs so the workspace
|
||||
/// can pin the callout position before the layout shifts.
|
||||
TabLayoutToggled,
|
||||
}
|
||||
|
||||
pub struct HoaOnboardingFlow {
|
||||
step: HoaOnboardingStep,
|
||||
/// When `true`, the user dismissed the welcome banner without clicking
|
||||
/// "See what's new". We show only the vertical-tabs callout with a
|
||||
/// "Dismiss" button and no progress dots.
|
||||
truncated_flow: bool,
|
||||
|
||||
// Step 1 state
|
||||
close_button: ViewHandle<ActionButton>,
|
||||
cta_button: ViewHandle<ActionButton>,
|
||||
|
||||
// Step 2 state
|
||||
horizontal_tabs_checkbox_mouse_state: MouseStateHandle,
|
||||
next_vtabs_button: ViewHandle<ActionButton>,
|
||||
dismiss_vtabs_button: ViewHandle<ActionButton>,
|
||||
|
||||
// Step 3 state
|
||||
next_inbox_button: ViewHandle<ActionButton>,
|
||||
|
||||
// Step 4 state
|
||||
finish_button: ViewHandle<ActionButton>,
|
||||
session_types: Vec<SessionType>,
|
||||
selected_session_type_index: usize,
|
||||
selected_directory: PathBuf,
|
||||
is_git_repo: bool,
|
||||
enable_worktree: bool,
|
||||
autogenerate_worktree_branch_name: bool,
|
||||
session_pill_mouse_states: Vec<MouseStateHandle>,
|
||||
directory_button_mouse_state: MouseStateHandle,
|
||||
worktree_checkbox_mouse_state: MouseStateHandle,
|
||||
worktree_tooltip_mouse_state: MouseStateHandle,
|
||||
autogenerate_checkbox_mouse_state: MouseStateHandle,
|
||||
autogenerate_tooltip_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl HoaOnboardingFlow {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let show_oz = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
|
||||
let session_types = session_config_rendering::visible_session_types(show_oz);
|
||||
let pill_mouse_states: Vec<_> = session_types
|
||||
.iter()
|
||||
.map(|_| MouseStateHandle::default())
|
||||
.collect();
|
||||
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
||||
let is_git = is_git_repo(&home);
|
||||
|
||||
let close_button = ctx.add_view(|_ctx| {
|
||||
ActionButton::new("", HoaWelcomeModalCloseButtonTheme)
|
||||
.with_icon(Icon::X)
|
||||
.with_size(ButtonSize::Small)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(HoaOnboardingAction::Dismiss))
|
||||
});
|
||||
|
||||
let cta_button = ctx.add_view(|_ctx| {
|
||||
ActionButton::new("See what's new", HoaWelcomeModalButtonTheme)
|
||||
.with_full_width(true)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(HoaOnboardingAction::AdvanceFromWelcome))
|
||||
});
|
||||
|
||||
let enter = Keystroke::parse("enter").unwrap_or_default();
|
||||
|
||||
let next_vtabs_button = ctx.add_view(|ctx| {
|
||||
ActionButton::new("Next", HoaPrimaryButtonTheme)
|
||||
.with_keybinding(KeystrokeSource::Fixed(enter.clone()), ctx)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(HoaOnboardingAction::AdvanceFromVerticalTabs)
|
||||
})
|
||||
});
|
||||
|
||||
let dismiss_vtabs_button = ctx.add_view(|ctx| {
|
||||
ActionButton::new("Dismiss", HoaPrimaryButtonTheme)
|
||||
.with_keybinding(KeystrokeSource::Fixed(enter.clone()), ctx)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(HoaOnboardingAction::Dismiss))
|
||||
});
|
||||
|
||||
let next_inbox_button = ctx.add_view(|ctx| {
|
||||
ActionButton::new("Next", HoaPrimaryButtonTheme)
|
||||
.with_keybinding(KeystrokeSource::Fixed(enter.clone()), ctx)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(HoaOnboardingAction::AdvanceFromInbox))
|
||||
});
|
||||
|
||||
let finish_button = ctx.add_view(|ctx| {
|
||||
ActionButton::new("Finish", HoaPrimaryButtonTheme)
|
||||
.with_keybinding(KeystrokeSource::Fixed(enter), ctx)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(HoaOnboardingAction::Finish))
|
||||
});
|
||||
|
||||
Self {
|
||||
step: HoaOnboardingStep::WelcomeBanner,
|
||||
truncated_flow: false,
|
||||
close_button,
|
||||
cta_button,
|
||||
horizontal_tabs_checkbox_mouse_state: MouseStateHandle::default(),
|
||||
next_vtabs_button,
|
||||
dismiss_vtabs_button,
|
||||
next_inbox_button,
|
||||
finish_button,
|
||||
session_types,
|
||||
selected_session_type_index: 0,
|
||||
selected_directory: home,
|
||||
is_git_repo: is_git,
|
||||
enable_worktree: false,
|
||||
autogenerate_worktree_branch_name: false,
|
||||
session_pill_mouse_states: pill_mouse_states,
|
||||
directory_button_mouse_state: MouseStateHandle::default(),
|
||||
worktree_checkbox_mouse_state: MouseStateHandle::default(),
|
||||
worktree_tooltip_mouse_state: MouseStateHandle::default(),
|
||||
autogenerate_checkbox_mouse_state: MouseStateHandle::default(),
|
||||
autogenerate_tooltip_mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn step(&self) -> HoaOnboardingStep {
|
||||
self.step
|
||||
}
|
||||
|
||||
fn selected_session_type(&self) -> SessionType {
|
||||
self.session_types[self.selected_session_type_index]
|
||||
}
|
||||
|
||||
fn advance(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
// In truncated mode, there are no steps after the vertical-tabs callout.
|
||||
if self.truncated_flow && self.step == HoaOnboardingStep::VerticalTabsCallout {
|
||||
ctx.emit(HoaOnboardingFlowEvent::Dismissed);
|
||||
return;
|
||||
}
|
||||
|
||||
self.step = match self.step {
|
||||
HoaOnboardingStep::WelcomeBanner => HoaOnboardingStep::VerticalTabsCallout,
|
||||
HoaOnboardingStep::VerticalTabsCallout => HoaOnboardingStep::AgentInboxCallout,
|
||||
HoaOnboardingStep::AgentInboxCallout => HoaOnboardingStep::TabConfig,
|
||||
HoaOnboardingStep::TabConfig => {
|
||||
self.finish(ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Emit StepChanged so the workspace re-renders and switches
|
||||
// from add_child to add_positioned_child for the new step.
|
||||
ctx.emit(HoaOnboardingFlowEvent::StepChanged);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn finish(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(HoaOnboardingFlowEvent::Completed(Some(
|
||||
SessionConfigSelection {
|
||||
session_type: self.selected_session_type(),
|
||||
directory: self.selected_directory.clone(),
|
||||
enable_worktree: self.enable_worktree,
|
||||
autogenerate_worktree_branch_name: self.autogenerate_worktree_branch_name,
|
||||
},
|
||||
)));
|
||||
}
|
||||
|
||||
fn dismiss(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.step == HoaOnboardingStep::WelcomeBanner {
|
||||
// User dismissed the welcome banner without clicking "See what's new".
|
||||
// Show only the vertical-tabs callout in truncated mode.
|
||||
self.truncated_flow = true;
|
||||
self.step = HoaOnboardingStep::VerticalTabsCallout;
|
||||
ctx.emit(HoaOnboardingFlowEvent::StepChanged);
|
||||
ctx.notify();
|
||||
} else {
|
||||
ctx.emit(HoaOnboardingFlowEvent::Dismissed);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering helpers ──
|
||||
|
||||
fn render_progress_dots(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let _ = appearance;
|
||||
let active_index = self.step.index();
|
||||
|
||||
let mut row = Flex::row().with_spacing(4.);
|
||||
for i in 0..HoaOnboardingStep::total_dots() {
|
||||
let fill = if i == active_index {
|
||||
Fill::Solid(PhenomenonStyle::blue())
|
||||
} else {
|
||||
Fill::Solid(PhenomenonStyle::subtle_border())
|
||||
};
|
||||
row.add_child(
|
||||
ConstrainedBox::new(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.with_background(fill)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(8.)
|
||||
.with_height(8.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_callout_footer(
|
||||
&self,
|
||||
button: &ViewHandle<ActionButton>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max);
|
||||
|
||||
if self.truncated_flow {
|
||||
// No progress dots – right-align the dismiss button.
|
||||
row = row.with_main_axis_alignment(MainAxisAlignment::End);
|
||||
} else {
|
||||
row = row.with_main_axis_alignment(MainAxisAlignment::SpaceBetween);
|
||||
row.add_child(self.render_progress_dots(appearance));
|
||||
}
|
||||
row.add_child(ChildView::new(button).finish());
|
||||
|
||||
Container::new(row.finish())
|
||||
.with_horizontal_padding(16.)
|
||||
.with_vertical_padding(16.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_callout_content(
|
||||
&self,
|
||||
title: &'static str,
|
||||
description: &'static str,
|
||||
extra_child: Option<Box<dyn Element>>,
|
||||
button: &ViewHandle<ActionButton>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let title = Text::new(title, appearance.ui_font_family(), 16.)
|
||||
.with_color(callout_title_color(appearance))
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish();
|
||||
|
||||
let description = Text::new(description, appearance.ui_font_family(), 14.)
|
||||
.with_color(callout_body_color(appearance))
|
||||
.finish();
|
||||
|
||||
let mut body_content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(description).with_margin_top(8.).finish());
|
||||
|
||||
if let Some(extra_child) = extra_child {
|
||||
body_content.add_child(Container::new(extra_child).with_margin_top(8.).finish());
|
||||
}
|
||||
|
||||
let body = Container::new(body_content.finish())
|
||||
.with_horizontal_padding(16.)
|
||||
.with_padding_top(16.)
|
||||
.with_padding_bottom(12.)
|
||||
.finish();
|
||||
let footer = self.render_callout_footer(button, appearance);
|
||||
|
||||
Flex::column().with_child(body).with_child(footer).finish()
|
||||
}
|
||||
|
||||
fn render_vertical_tabs_callout(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let use_vertical = *TabSettings::as_ref(app).use_vertical_tabs;
|
||||
let checkbox_mouse = self.horizontal_tabs_checkbox_mouse_state.clone();
|
||||
let checkbox_el = callout_checkbox(checkbox_mouse, Some(10.5), appearance)
|
||||
.check(!use_vertical)
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(HoaOnboardingAction::ToggleHorizontalTabs);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let checkbox_label = Text::new_inline(
|
||||
"Switch back to horizontal tabs".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(callout_label_color(appearance))
|
||||
.finish();
|
||||
|
||||
let checkbox_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.)
|
||||
.with_child(checkbox_el)
|
||||
.with_child(checkbox_label)
|
||||
.finish();
|
||||
|
||||
let button = if self.truncated_flow {
|
||||
&self.dismiss_vtabs_button
|
||||
} else {
|
||||
&self.next_vtabs_button
|
||||
};
|
||||
|
||||
self.render_callout_content(
|
||||
"Introducing vertical tabs - the new default",
|
||||
"Vertical tabs show all open agent and terminal panes, grouped by tab. Customize what information you want to see to support your workflow.",
|
||||
Some(checkbox_row),
|
||||
button,
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_inbox_callout(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let title = Text::new(
|
||||
"Meet your new agent inbox",
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
)
|
||||
.with_color(callout_title_color(appearance))
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish();
|
||||
|
||||
// Build the description with an inline "Learn more" hyperlink.
|
||||
let learn_more_fragment = FormattedTextFragment {
|
||||
text: "Learn more".into(),
|
||||
styles: FormattedTextStyles {
|
||||
underline: true,
|
||||
hyperlink: Some(Hyperlink::Url(
|
||||
"https://docs.warp.dev/agent-platform/warp-agents/agent-notifications".into(),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let formatted = FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(
|
||||
"Warp pipes through notifications from any CLI coding agent into a unified notification center that works across all coding agents and harnesses. ",
|
||||
),
|
||||
learn_more_fragment,
|
||||
])]);
|
||||
|
||||
let description = FormattedTextElement::new(
|
||||
formatted,
|
||||
14.,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
callout_body_color(appearance),
|
||||
Default::default(),
|
||||
)
|
||||
.with_line_height_ratio(1.2)
|
||||
.register_default_click_handlers(|link, _ctx, app| {
|
||||
app.open_url(&link.url);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let body_content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(description).with_margin_top(8.).finish())
|
||||
.finish();
|
||||
|
||||
let body = Container::new(body_content)
|
||||
.with_horizontal_padding(16.)
|
||||
.with_padding_top(16.)
|
||||
.with_padding_bottom(12.)
|
||||
.finish();
|
||||
let footer = self.render_callout_footer(&self.next_inbox_button, appearance);
|
||||
|
||||
Flex::column().with_child(body).with_child(footer).finish()
|
||||
}
|
||||
|
||||
fn render_tab_config_step(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let form = tab_config_step::render_tab_config_form(
|
||||
tab_config_step::TabConfigFormState {
|
||||
session_types: &self.session_types,
|
||||
selected_session_type_index: self.selected_session_type_index,
|
||||
session_pill_mouse_states: &self.session_pill_mouse_states,
|
||||
selected_directory: &self.selected_directory,
|
||||
directory_button_mouse_state: self.directory_button_mouse_state.clone(),
|
||||
enable_worktree: self.enable_worktree,
|
||||
is_git_repo: self.is_git_repo,
|
||||
worktree_checkbox_mouse_state: self.worktree_checkbox_mouse_state.clone(),
|
||||
worktree_tooltip_mouse_state: self.worktree_tooltip_mouse_state.clone(),
|
||||
autogenerate_worktree_branch_name: self.autogenerate_worktree_branch_name,
|
||||
autogenerate_checkbox_mouse_state: self.autogenerate_checkbox_mouse_state.clone(),
|
||||
autogenerate_tooltip_mouse_state: self.autogenerate_tooltip_mouse_state.clone(),
|
||||
},
|
||||
tab_config_step::TabConfigFormHandlers {
|
||||
on_select_session_type: |i: usize, ctx: &mut EventContext, _: Vector2F| {
|
||||
ctx.dispatch_typed_action(HoaOnboardingAction::SelectSessionType(i));
|
||||
},
|
||||
on_open_directory_picker: |ctx: &mut EventContext, _: Vector2F| {
|
||||
ctx.dispatch_typed_action(HoaOnboardingAction::OpenDirectoryPicker);
|
||||
},
|
||||
on_toggle_worktree: |ctx: &mut EventContext, _: Vector2F| {
|
||||
ctx.dispatch_typed_action(HoaOnboardingAction::ToggleWorktree);
|
||||
},
|
||||
on_toggle_autogenerate: |ctx: &mut EventContext, _: Vector2F| {
|
||||
ctx.dispatch_typed_action(
|
||||
HoaOnboardingAction::ToggleAutogenerateWorktreeBranchName,
|
||||
);
|
||||
},
|
||||
},
|
||||
appearance,
|
||||
);
|
||||
|
||||
let footer = self.render_callout_footer(&self.finish_button, appearance);
|
||||
|
||||
let body = Container::new(form)
|
||||
.with_horizontal_padding(16.)
|
||||
.with_vertical_padding(16.)
|
||||
.finish();
|
||||
|
||||
Flex::column().with_child(body).with_child(footer).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for HoaOnboardingFlow {
|
||||
type Event = HoaOnboardingFlowEvent;
|
||||
}
|
||||
|
||||
impl View for HoaOnboardingFlow {
|
||||
fn ui_name() -> &'static str {
|
||||
"HoaOnboardingFlow"
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
match self.step {
|
||||
HoaOnboardingStep::WelcomeBanner => {
|
||||
// Full-window scrim with centered banner
|
||||
let banner = welcome_banner::render_welcome_banner(
|
||||
&self.close_button,
|
||||
&self.cta_button,
|
||||
appearance,
|
||||
);
|
||||
|
||||
Container::new(Align::new(banner).finish())
|
||||
.with_background_color(ColorU::new(18, 18, 18, 128))
|
||||
.finish()
|
||||
}
|
||||
HoaOnboardingStep::VerticalTabsCallout => {
|
||||
let content = self.render_vertical_tabs_callout(appearance, app);
|
||||
let use_vertical = *TabSettings::as_ref(app).use_vertical_tabs;
|
||||
let (arrow_direction, arrow_position) = if use_vertical {
|
||||
(
|
||||
CalloutArrowDirection::Left,
|
||||
CalloutArrowPosition::Start(16.),
|
||||
)
|
||||
} else {
|
||||
(CalloutArrowDirection::Up, CalloutArrowPosition::Start(24.))
|
||||
};
|
||||
render_callout_bubble(
|
||||
content,
|
||||
&CalloutBubbleConfig {
|
||||
width: CALLOUT_WIDTH,
|
||||
arrow_direction,
|
||||
arrow_position,
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
HoaOnboardingStep::AgentInboxCallout => {
|
||||
let content = self.render_inbox_callout(appearance);
|
||||
render_callout_bubble(
|
||||
content,
|
||||
&CalloutBubbleConfig {
|
||||
width: CALLOUT_WIDTH,
|
||||
arrow_direction: CalloutArrowDirection::Up,
|
||||
arrow_position: CalloutArrowPosition::End(24.),
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
HoaOnboardingStep::TabConfig => {
|
||||
let tab_content = self.render_tab_config_step(appearance);
|
||||
let use_vertical = *TabSettings::as_ref(app).use_vertical_tabs;
|
||||
let (arrow_direction, arrow_position) = if use_vertical {
|
||||
(
|
||||
CalloutArrowDirection::Left,
|
||||
CalloutArrowPosition::Start(16.),
|
||||
)
|
||||
} else {
|
||||
(CalloutArrowDirection::Up, CalloutArrowPosition::Center)
|
||||
};
|
||||
render_callout_bubble(
|
||||
tab_content,
|
||||
&CalloutBubbleConfig {
|
||||
width: CALLOUT_WIDTH,
|
||||
arrow_direction,
|
||||
arrow_position,
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for HoaOnboardingFlow {
|
||||
type Action = HoaOnboardingAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
HoaOnboardingAction::EnterPressed => {
|
||||
self.advance(ctx);
|
||||
}
|
||||
HoaOnboardingAction::AdvanceFromWelcome
|
||||
| HoaOnboardingAction::AdvanceFromVerticalTabs
|
||||
| HoaOnboardingAction::AdvanceFromInbox => {
|
||||
self.advance(ctx);
|
||||
}
|
||||
HoaOnboardingAction::ToggleHorizontalTabs => {
|
||||
// Emit before toggling so workspace can pin the callout position.
|
||||
ctx.emit(HoaOnboardingFlowEvent::TabLayoutToggled);
|
||||
let current = *TabSettings::as_ref(ctx).use_vertical_tabs;
|
||||
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
let _ = settings.use_vertical_tabs.set_value(!current, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
HoaOnboardingAction::SelectSessionType(index) => {
|
||||
self.selected_session_type_index = *index;
|
||||
ctx.notify();
|
||||
}
|
||||
HoaOnboardingAction::OpenDirectoryPicker => {
|
||||
ctx.open_file_picker(
|
||||
|result, ctx| {
|
||||
if let Some(path_result) =
|
||||
result.map(|paths| paths.into_iter().next()).transpose()
|
||||
{
|
||||
ctx.dispatch_typed_action(&HoaOnboardingAction::DirectorySelected(
|
||||
path_result,
|
||||
));
|
||||
}
|
||||
},
|
||||
FilePickerConfiguration::new().folders_only(),
|
||||
);
|
||||
}
|
||||
HoaOnboardingAction::DirectorySelected(result) => match result {
|
||||
Ok(path) => {
|
||||
let path = PathBuf::from(path);
|
||||
self.is_git_repo = is_git_repo(&path);
|
||||
if !self.is_git_repo {
|
||||
self.enable_worktree = false;
|
||||
self.autogenerate_worktree_branch_name = false;
|
||||
}
|
||||
self.selected_directory = path;
|
||||
ctx.notify();
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("File picker error in HOA onboarding: {err}");
|
||||
}
|
||||
},
|
||||
HoaOnboardingAction::ToggleWorktree => {
|
||||
if self.is_git_repo {
|
||||
self.enable_worktree = !self.enable_worktree;
|
||||
if !self.enable_worktree {
|
||||
self.autogenerate_worktree_branch_name = false;
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
HoaOnboardingAction::ToggleAutogenerateWorktreeBranchName => {
|
||||
if self.enable_worktree {
|
||||
self.autogenerate_worktree_branch_name =
|
||||
!self.autogenerate_worktree_branch_name;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
HoaOnboardingAction::Finish => {
|
||||
self.finish(ctx);
|
||||
}
|
||||
HoaOnboardingAction::Dismiss => {
|
||||
self.dismiss(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
mod hoa_onboarding_flow;
|
||||
mod tab_config_step;
|
||||
mod welcome_banner;
|
||||
|
||||
pub use hoa_onboarding_flow::{init, HoaOnboardingFlow, HoaOnboardingFlowEvent, HoaOnboardingStep};
|
||||
|
||||
use warpui::AppContext;
|
||||
|
||||
use warp_core::user_preferences::GetUserPreferences;
|
||||
|
||||
const HAS_COMPLETED_HOA_ONBOARDING_KEY: &str = "HasCompletedHOAOnboarding";
|
||||
|
||||
pub fn has_completed_hoa_onboarding(ctx: &AppContext) -> bool {
|
||||
ctx.private_user_preferences()
|
||||
.read_value(HAS_COMPLETED_HOA_ONBOARDING_KEY)
|
||||
.unwrap_or_default()
|
||||
.and_then(|s| serde_json::from_str::<bool>(&s).ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn mark_hoa_onboarding_completed(ctx: &AppContext) {
|
||||
let _ = ctx.private_user_preferences().write_value(
|
||||
HAS_COMPLETED_HOA_ONBOARDING_KEY,
|
||||
serde_json::to_string(&true).expect("bool serializes to JSON"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use std::path::Path;
|
||||
|
||||
use warpui::elements::{
|
||||
Container, CrossAxisAlignment, Flex, MouseStateHandle, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::geometry::vector::Vector2F;
|
||||
use warpui::Element;
|
||||
use warpui::EventContext;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::tab_configs::session_config::SessionType;
|
||||
use crate::tab_configs::session_config_rendering;
|
||||
use crate::view_components::callout_bubble::{
|
||||
callout_background_fill, callout_body_color, callout_title_color,
|
||||
};
|
||||
|
||||
const SECTION_GAP: f32 = 16.;
|
||||
|
||||
pub struct TabConfigFormState<'a> {
|
||||
pub session_types: &'a [SessionType],
|
||||
pub selected_session_type_index: usize,
|
||||
pub session_pill_mouse_states: &'a [MouseStateHandle],
|
||||
pub selected_directory: &'a Path,
|
||||
pub directory_button_mouse_state: MouseStateHandle,
|
||||
pub enable_worktree: bool,
|
||||
pub is_git_repo: bool,
|
||||
pub worktree_checkbox_mouse_state: MouseStateHandle,
|
||||
pub worktree_tooltip_mouse_state: MouseStateHandle,
|
||||
pub autogenerate_worktree_branch_name: bool,
|
||||
pub autogenerate_checkbox_mouse_state: MouseStateHandle,
|
||||
pub autogenerate_tooltip_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct TabConfigFormHandlers<F1, F2, F3, F4> {
|
||||
pub on_select_session_type: F1,
|
||||
pub on_open_directory_picker: F2,
|
||||
pub on_toggle_worktree: F3,
|
||||
pub on_toggle_autogenerate: F4,
|
||||
}
|
||||
|
||||
/// Renders the tab config form content (session type + directory + worktree).
|
||||
///
|
||||
/// This is the body of Step 4, without the surrounding popover chrome.
|
||||
/// Action callbacks are passed in so the caller owns the dispatch logic.
|
||||
pub fn render_tab_config_form<F1, F2, F3, F4>(
|
||||
state: TabConfigFormState<'_>,
|
||||
handlers: TabConfigFormHandlers<F1, F2, F3, F4>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F1: Fn(usize, &mut EventContext, Vector2F) + 'static,
|
||||
F2: Fn(&mut EventContext, Vector2F) + 'static,
|
||||
F3: Fn(&mut EventContext, Vector2F) + 'static,
|
||||
F4: Fn(&mut EventContext, Vector2F) + 'static,
|
||||
{
|
||||
let callout_bg = callout_background_fill(appearance).into_solid();
|
||||
let title = Text::new(
|
||||
"Create your first tab config",
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
)
|
||||
.with_color(callout_title_color(appearance))
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish();
|
||||
|
||||
let description = Text::new(
|
||||
"Set up a reusable starting point for your tabs. Pick a repo, choose a session type, and optionally attach a worktree. Use it whenever you want to open a tab with this setup.",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(callout_body_color(appearance))
|
||||
.finish();
|
||||
|
||||
let session_type_section = session_config_rendering::render_session_type_pills_with_background(
|
||||
state.session_types,
|
||||
state.selected_session_type_index,
|
||||
state.session_pill_mouse_states,
|
||||
handlers.on_select_session_type,
|
||||
Some(callout_bg),
|
||||
appearance,
|
||||
);
|
||||
|
||||
let directory_section = session_config_rendering::render_directory_picker_with_background(
|
||||
state.selected_directory,
|
||||
state.directory_button_mouse_state,
|
||||
handlers.on_open_directory_picker,
|
||||
Some(callout_bg),
|
||||
appearance,
|
||||
);
|
||||
|
||||
let worktree_section = session_config_rendering::render_worktree_checkbox_with_background(
|
||||
state.enable_worktree,
|
||||
state.is_git_repo,
|
||||
state.worktree_checkbox_mouse_state,
|
||||
state.worktree_tooltip_mouse_state,
|
||||
handlers.on_toggle_worktree,
|
||||
Some(callout_bg),
|
||||
appearance,
|
||||
);
|
||||
|
||||
let autogenerate_section =
|
||||
session_config_rendering::render_autogenerate_worktree_branch_name_checkbox_with_background(
|
||||
state.autogenerate_worktree_branch_name,
|
||||
state.enable_worktree,
|
||||
state.autogenerate_checkbox_mouse_state,
|
||||
state.autogenerate_tooltip_mouse_state,
|
||||
handlers.on_toggle_autogenerate,
|
||||
Some(callout_bg),
|
||||
appearance,
|
||||
);
|
||||
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(description).with_margin_top(8.).finish())
|
||||
.with_child(
|
||||
Container::new(session_type_section)
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(directory_section)
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(worktree_section)
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(autogenerate_section)
|
||||
.with_margin_top(8.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::elements::{
|
||||
CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, Expanded, Flex, Image, MainAxisSize, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::Element;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::ActionButton;
|
||||
|
||||
use warpui::ViewHandle;
|
||||
|
||||
const BANNER_WIDTH: f32 = 420.;
|
||||
const HERO_HEIGHT: f32 = 92.;
|
||||
const HERO_IMAGE_PATH: &str = "async/png/onboarding/hoa_welcome_banner.png";
|
||||
|
||||
struct FeatureItem {
|
||||
icon: Icon,
|
||||
title: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
const FEATURE_ITEMS: &[FeatureItem] = &[
|
||||
FeatureItem {
|
||||
icon: Icon::LayoutAlt01,
|
||||
title: "Vertical tabs",
|
||||
description: "Rich tab titles and metadata like git branch, worktree, and PR. Fully customizable.",
|
||||
},
|
||||
FeatureItem {
|
||||
icon: Icon::Sliders,
|
||||
title: "Tab configs",
|
||||
description: "Tab-level schema to set your directory, startup commands, theme, and worktree with one click",
|
||||
},
|
||||
FeatureItem {
|
||||
icon: Icon::Inbox,
|
||||
title: "Agent inbox",
|
||||
description: "Notifications when any agent needs your attention, also accessible in a central inbox",
|
||||
},
|
||||
FeatureItem {
|
||||
icon: Icon::MessageCheckSquare,
|
||||
title: "Native code review",
|
||||
description: "Send inline comments from Warp's code review directly to Claude Code, Codex, or OpenCode",
|
||||
},
|
||||
];
|
||||
|
||||
pub fn render_welcome_banner(
|
||||
close_button: &ViewHandle<ActionButton>,
|
||||
cta_button: &ViewHandle<ActionButton>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
// Hero image with close button overlay
|
||||
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(BANNER_WIDTH)
|
||||
.with_height(HERO_HEIGHT)
|
||||
.finish();
|
||||
|
||||
let close_el = Container::new(ChildView::new(close_button).finish()).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,
|
||||
),
|
||||
);
|
||||
|
||||
// "New" badge
|
||||
let badge = 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();
|
||||
|
||||
// Title
|
||||
let title = Text::new(
|
||||
"Introducing universal agent support: level up any coding agent with Warp",
|
||||
appearance.ui_font_family(),
|
||||
20.,
|
||||
)
|
||||
.with_color(PhenomenonStyle::modal_title_text())
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.finish();
|
||||
|
||||
// Feature list
|
||||
let mut features_col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(12.);
|
||||
|
||||
for item in FEATURE_ITEMS {
|
||||
let icon_el = ConstrainedBox::new(
|
||||
item.icon
|
||||
.to_warpui_icon(Fill::Solid(PhenomenonStyle::modal_feature_title_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(
|
||||
Text::new(item.description, appearance.ui_font_family(), 14.)
|
||||
.with_color(PhenomenonStyle::modal_feature_description_text())
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(10.)
|
||||
.with_child(icon_el)
|
||||
.with_child(Expanded::new(1., text_col).finish())
|
||||
.finish();
|
||||
|
||||
features_col.add_child(row);
|
||||
}
|
||||
|
||||
// CTA button
|
||||
let cta = ChildView::new(cta_button).finish();
|
||||
|
||||
// Body content
|
||||
let body = 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(badge)
|
||||
.with_child(title)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(features_col.finish())
|
||||
.with_margin_top(12.)
|
||||
.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();
|
||||
|
||||
// Full banner
|
||||
ConstrainedBox::new(
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(hero_stack.finish())
|
||||
.with_child(body)
|
||||
.finish(),
|
||||
)
|
||||
.with_background(Fill::Solid(PhenomenonStyle::modal_background()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(BANNER_WIDTH)
|
||||
.finish()
|
||||
}
|
||||
Reference in New Issue
Block a user