--- name: create-launch-modal description: Create a one-time launch modal in the Warp client (feature announcement, onboarding, etc.). Use when adding a new modal that should appear exactly once per user on startup, gated by a feature flag, with colors sourced from Warp theme tokens and terminal theme colors. --- # create-launch-modal Create a one-time launch modal — the feature-announcement design used for launches like "Orchestrate any agent, anywhere" or "Warp is now open-source." ## Reference implementation `app/src/workspace/view/orchestration_launch_modal/` — the canonical, most up-to-date example of this pattern. ## Checklist - [ ] Feature flag in `warp_features/src/lib.rs` - [ ] Settings field in `app/src/settings/ai.rs` - [ ] Trigger logic in `app/src/workspace/one_time_modal_model.rs` - [ ] View files under `app/src/workspace/view/_launch_modal/` - [ ] Workspace wiring in `app/src/workspace/view.rs` and `app/src/workspace/mod.rs` - [ ] Debug actions in `app/src/workspace/action.rs` - [ ] Hero image at `app/assets/async/png/onboarding/_launch_banner.png` - [ ] Any custom icons added to `crates/warp_core/src/ui/icons.rs` + SVG in `app/assets/bundled/svg/` --- ## Step 0 – Custom icons (if needed) If the modal uses icons not yet in the `Icon` enum, add them before writing the view. In `crates/warp_core/src/ui/icons.rs`: ```rust // Add to enum YourIconName, // Add to From for &'static str match Icon::YourIconName => "bundled/svg/your-icon-name.svg", ``` Drop the SVG file at `app/assets/bundled/svg/your-icon-name.svg`. Use the same 24×24 viewBox format as existing icons. --- ## Step 1 – Feature flag Add to `crates/warp_features/src/lib.rs`: ```rust /// Enables the launch modal. LaunchModal, ``` Enable for dogfood: ```rust pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::LaunchModal, // ... ]; ``` --- ## Step 2 – Settings field Add to `app/src/settings/ai.rs` inside `define_settings_group!(AISettings, ...)`. Pattern: one boolean field per modal, globally synced (not respecting user sync), private. ```rust // This is not a user-visible setting - it's merely a one-time flag to track if the // launch modal has been shown to the user. // // We model it as a setting so it's only shown once to a given user regardless of the number of // devices they use. did_check_to_trigger__launch_modal: DidShowLaunchModal { type: bool, default: false, supported_platforms: SupportedPlatforms::ALL, sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No), private: true, } ``` --- ## Step 3 – OneTimeModalModel File: `app/src/workspace/one_time_modal_model.rs` ### 3a. Add field to struct ```rust is__launch_modal_open: bool, ``` ### 3b. Initialize to false in `new()` ```rust is__launch_modal_open: false, ``` ### 3c. Pre-dismiss for new users (critical) In the `AuthComplete` → `!is_existing_user` branch, add to the `AISettings::handle` update block alongside the other pre-dismissals. **Without this, new users see the modal on their second startup after onboarding.** ```rust if let Err(e) = settings .did_check_to_trigger__launch_modal .set_value(true, ctx) { log::warn!("Failed to mark launch modal as dismissed: {e}"); } ``` ### 3d. Public API methods ```rust pub fn is__launch_modal_open(&self) -> bool { self.is__launch_modal_open && self.target_window_id.is_some() } pub fn mark__launch_modal_dismissed(&mut self, ctx: &mut ModelContext) { self.set__launch_modal_open(false, ctx); } #[cfg(debug_assertions)] pub fn force_open__launch_modal(&mut self, ctx: &mut ModelContext) { self.set__launch_modal_open(true, ctx); } ``` ### 3e. Private setter ```rust fn set__launch_modal_open(&mut self, is_open: bool, ctx: &mut ModelContext) -> bool { if self.is__launch_modal_open != is_open { self.is__launch_modal_open = is_open; ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open }); return true; } false } ``` ### 3f. Add to `is_any_modal_open` ```rust || self.is__launch_modal_open ``` ### 3g. Trigger function ```rust fn check_and_trigger__launch_modal(&mut self, ctx: &mut ModelContext) -> bool { if !FeatureFlag::LaunchModal.is_enabled() { return false; } let ai_settings = AISettings::as_ref(ctx); if *ai_settings.did_check_to_trigger__launch_modal { return false; } AISettings::handle(ctx).update(ctx, |settings, ctx| { if let Err(e) = settings .did_check_to_trigger__launch_modal .set_value(true, ctx) { log::warn!("Failed to mark launch modal as dismissed: {e}"); } }); let should_show = !matches!(ChannelState::channel(), Channel::Integration); self.set__launch_modal_open(should_show, ctx); should_show } ``` ### 3h. Call from `check_and_trigger_all_modals` Insert before `check_and_trigger_hoa_onboarding`: ```rust if self.check_and_trigger__launch_modal(ctx) { return; } ``` --- ## Step 4 – View Create `app/src/workspace/view/_launch_modal/mod.rs`: ```rust mod view; pub use view::{init, LaunchModal, LaunchModalEvent}; ``` Create `app/src/workspace/view/_launch_modal/view.rs`. Copy from `orchestration_launch_modal/view.rs` and adapt. Key details: ### Color sources (important) - Prefer Warp theme tokens for modal backgrounds, text, overlays, and borders: - background surfaces: `appearance.theme().surface_3()` (or another `surface_*` token when needed) - primary/subtext: `appearance.theme().main_text_color(...)` and `appearance.theme().sub_text_color(...)` - overlays/hover fills: `appearance.theme().surface_overlay_1()` / `surface_overlay_2()` - subtle borders: `appearance.theme().outline()` - Use terminal theme colors for terminal-color accents (for example, magenta launch badge accents): - `appearance.theme().terminal_colors().normal.magenta` - `appearance.theme().ansi_overlay_1(magenta)` for low-alpha backgrounds - Avoid hardcoded hex colors. ### Hero image - Store at `app/assets/async/png/onboarding/_launch_banner.png` - **Aspect ratio matters**: if the image is wider than `MODAL_WIDTH/HERO_HEIGHT` (420/92 ≈ 4.57), wrap the hero `ConstrainedBox` in `Clipped::new(...)` to prevent horizontal bleed when `cover()` scales it - Images pre-sized to exactly 420×92 need no `Clipped`; images only taller (aspect ratio < 4.57) are fine without it ```rust const MODAL_WIDTH: f32 = 420.; const HERO_HEIGHT: f32 = 92.; const HERO_IMAGE_PATH: &str = "async/png/onboarding/_launch_banner.png"; fn render_hero(&self) -> Box { let hero = Clipped::new( // only needed if image ratio > 4.57 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(), ) .finish(); // ... close button overlay via Stack + add_positioned_child } ``` ### "New" badge Use the standard badge — 24 px tall, 8 px horizontal padding, 14 px font, pill corners, with magenta sourced from terminal theme colors: ```rust fn render_badge(appearance: &Appearance) -> Box { let magenta = appearance.theme().terminal_colors().normal.magenta; let text = Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.) .with_color(magenta.into()) .finish(); ConstrainedBox::new( Container::new( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min) .with_child(text) .finish(), ) .with_horizontal_padding(8.) .with_background(Fill::Solid(appearance.theme().ansi_overlay_1(magenta))) .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.))) .finish(), ) .with_height(24.) .finish() } ``` ### URLs Always use `https://`, not `http://`: ```rust const LEARN_MORE_URL: &str = "https://warp.dev/your-blog-link"; ``` --- ## Step 5 – Workspace wiring ### `app/src/workspace/view.rs` ```rust // Module declaration (top) pub(crate) mod _launch_modal; // Import use crate::workspace::view::_launch_modal::{LaunchModal, LaunchModalEvent}; // Struct field _launch_modal: ViewHandle<LaunchModal>, // In Workspace::new() let _launch_view = ctx.add_typed_action_view(LaunchModal::new); ctx.subscribe_to_view(&_launch_view, |me, _, event, ctx| { me.handle__launch_modal_event(event, ctx); }); // In struct initialization _launch_modal: _launch_view, // In OneTimeModalModel subscription handler } else if model_ref.is__launch_modal_open() { me.focus__launch_modal(ctx); // In View::render (inside the should_show_modal block) if should_show_modal && one_time_modal_model.is__launch_modal_open() { stack.add_child(ChildView::new(&self._launch_modal).finish()); } ``` Add event handler and focus helper: ```rust fn handle__launch_modal_event(&mut self, event: &LaunchModalEvent, ctx: &mut ViewContext) { match event { LaunchModalEvent::Close => { OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| { model.mark__launch_modal_dismissed(ctx); }); self.focus_active_tab(ctx); ctx.notify(); } } } fn focus__launch_modal(&mut self, ctx: &mut ViewContext) { ctx.focus(&self._launch_modal); } ``` ### `app/src/workspace/mod.rs` ```rust // In pub fn init() view::_launch_modal::init(app); // In debug bindings block EditableBinding::new( "workspace:open__launch_modal", "[Debug] Open Launch Modal", WorkspaceAction::OpenLaunchModal, ) .with_context_predicate(id!("Workspace")), EditableBinding::new( "workspace:reset__launch_modal_state", "[Debug] Reset Launch Modal State", WorkspaceAction::ResetLaunchModalState, ) .with_context_predicate(id!("Workspace")), ``` --- ## Step 6 – Debug actions In `app/src/workspace/action.rs`: ```rust /// Open the Launch Modal (for debugging) #[cfg(debug_assertions)] OpenLaunchModal, /// Reset the launch modal dismissed state (for debugging) #[cfg(debug_assertions)] ResetLaunchModalState, ``` Add both variants to the `is_visible_in_command_palette` `false` arm. In `app/src/workspace/view.rs` `TypedActionView::handle_action`: ```rust #[cfg(debug_assertions)] OpenLaunchModal => { OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| { model.force_open__launch_modal(ctx); }); ctx.notify(); } #[cfg(debug_assertions)] ResetLaunchModalState => { AISettings::handle(ctx).update(ctx, |settings, ctx| { if let Err(e) = settings .did_check_to_trigger__launch_modal .set_value(false, ctx) { log::warn!("Failed to reset launch modal state: {e}"); } }); } ``` --- ## Behavior summary | User type | Sees modal? | |---|---| | New signup | No — pre-dismissed in `AuthComplete` new-user branch | | Not signed in | No — trigger never fires without `AuthComplete` | | Existing user, flag enabled | Yes — on first startup after cloud prefs load | | Integration channel | No — suppressed by `Channel::Integration` check | | Already seen it | No — setting persists globally across devices |