| 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
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:
YourIconName,
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:
<YourModalName>LaunchModal,
Enable for dogfood:
pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[
FeatureFlag::<YourModalName>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.
did_check_to_trigger_<name>_launch_modal: DidShow<Name>LaunchModal {
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
is_<name>_launch_modal_open: bool,
3b. Initialize to false in new()
is_<name>_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.
if let Err(e) = settings
.did_check_to_trigger_<name>_launch_modal
.set_value(true, ctx)
{
log::warn!("Failed to mark <name> launch modal as dismissed: {e}");
}
3d. Public API methods
pub fn is_<name>_launch_modal_open(&self) -> bool {
self.is_<name>_launch_modal_open && self.target_window_id.is_some()
}
pub fn mark_<name>_launch_modal_dismissed(&mut self, ctx: &mut ModelContext<Self>) {
self.set_<name>_launch_modal_open(false, ctx);
}
#[cfg(debug_assertions)]
pub fn force_open_<name>_launch_modal(&mut self, ctx: &mut ModelContext<Self>) {
self.set_<name>_launch_modal_open(true, ctx);
}
3e. Private setter
fn set_<name>_launch_modal_open(&mut self, is_open: bool, ctx: &mut ModelContext<Self>) -> bool {
if self.is_<name>_launch_modal_open != is_open {
self.is_<name>_launch_modal_open = is_open;
ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open });
return true;
}
false
}
3f. Add to is_any_modal_open
|| self.is_<name>_launch_modal_open
3g. Trigger function
fn check_and_trigger_<name>_launch_modal(&mut self, ctx: &mut ModelContext<Self>) -> bool {
if !FeatureFlag::<Name>LaunchModal.is_enabled() {
return false;
}
let ai_settings = AISettings::as_ref(ctx);
if *ai_settings.did_check_to_trigger_<name>_launch_modal {
return false;
}
AISettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(e) = settings
.did_check_to_trigger_<name>_launch_modal
.set_value(true, ctx)
{
log::warn!("Failed to mark <name> launch modal as dismissed: {e}");
}
});
let should_show = !matches!(ChannelState::channel(), Channel::Integration);
self.set_<name>_launch_modal_open(should_show, ctx);
should_show
}
3h. Call from check_and_trigger_all_modals
Insert before check_and_trigger_hoa_onboarding:
if self.check_and_trigger_<name>_launch_modal(ctx) {
return;
}
Step 4 – View
Create app/src/workspace/view/<name>_launch_modal/mod.rs:
mod view;
pub use view::{init, <Name>LaunchModal, <Name>LaunchModalEvent};
Create app/src/workspace/view/<name>_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/<name>_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
const MODAL_WIDTH: f32 = 420.;
const HERO_HEIGHT: f32 = 92.;
const HERO_IMAGE_PATH: &str = "async/png/onboarding/<name>_launch_banner.png";
fn render_hero(&self) -> Box<dyn Element> {
let hero = Clipped::new(
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();
}
"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:
fn render_badge(appearance: &Appearance) -> Box<dyn Element> {
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://:
const LEARN_MORE_URL: &str = "https://warp.dev/your-blog-link";
Step 5 – Workspace wiring
app/src/workspace/view.rs
pub(crate) mod <name>_launch_modal;
use crate::workspace::view::<name>_launch_modal::{<Name>LaunchModal, <Name>LaunchModalEvent};
<name>_launch_modal: ViewHandle<<Name>LaunchModal>,
let <name>_launch_view = ctx.add_typed_action_view(<Name>LaunchModal::new);
ctx.subscribe_to_view(&<name>_launch_view, |me, _, event, ctx| {
me.handle_<name>_launch_modal_event(event, ctx);
});
<name>_launch_modal: <name>_launch_view,
} else if model_ref.is_<name>_launch_modal_open() {
me.focus_<name>_launch_modal(ctx);
if should_show_modal && one_time_modal_model.is_<name>_launch_modal_open() {
stack.add_child(ChildView::new(&self.<name>_launch_modal).finish());
}
Add event handler and focus helper:
fn handle_<name>_launch_modal_event(&mut self, event: &<Name>LaunchModalEvent, ctx: &mut ViewContext<Self>) {
match event {
<Name>LaunchModalEvent::Close => {
OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
model.mark_<name>_launch_modal_dismissed(ctx);
});
self.focus_active_tab(ctx);
ctx.notify();
}
}
}
fn focus_<name>_launch_modal(&mut self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.<name>_launch_modal);
}
app/src/workspace/mod.rs
view::<name>_launch_modal::init(app);
EditableBinding::new(
"workspace:open_<name>_launch_modal",
"[Debug] Open <Name> Launch Modal",
WorkspaceAction::Open<Name>LaunchModal,
)
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:reset_<name>_launch_modal_state",
"[Debug] Reset <Name> Launch Modal State",
WorkspaceAction::Reset<Name>LaunchModalState,
)
.with_context_predicate(id!("Workspace")),
Step 6 – Debug actions
In app/src/workspace/action.rs:
#[cfg(debug_assertions)]
Open<Name>LaunchModal,
#[cfg(debug_assertions)]
Reset<Name>LaunchModalState,
Add both variants to the is_visible_in_command_palette false arm.
In app/src/workspace/view.rs TypedActionView::handle_action:
#[cfg(debug_assertions)]
Open<Name>LaunchModal => {
OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
model.force_open_<name>_launch_modal(ctx);
});
ctx.notify();
}
#[cfg(debug_assertions)]
Reset<Name>LaunchModalState => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(e) = settings
.did_check_to_trigger_<name>_launch_modal
.set_value(false, ctx)
{
log::warn!("Failed to reset <name> 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 |