first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
---
|
||||
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/<name>_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/<name>_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<Icon> 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 <name> launch modal.
|
||||
<YourModalName>LaunchModal,
|
||||
```
|
||||
|
||||
Enable for dogfood:
|
||||
|
||||
```rust
|
||||
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.
|
||||
|
||||
```rust
|
||||
// This is not a user-visible setting - it's merely a one-time flag to track if the
|
||||
// <name> 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_<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
|
||||
|
||||
```rust
|
||||
is_<name>_launch_modal_open: bool,
|
||||
```
|
||||
|
||||
### 3b. Initialize to false in `new()`
|
||||
|
||||
```rust
|
||||
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.**
|
||||
|
||||
```rust
|
||||
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
|
||||
|
||||
```rust
|
||||
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
|
||||
|
||||
```rust
|
||||
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`
|
||||
|
||||
```rust
|
||||
|| self.is_<name>_launch_modal_open
|
||||
```
|
||||
|
||||
### 3g. Trigger function
|
||||
|
||||
```rust
|
||||
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`:
|
||||
|
||||
```rust
|
||||
if self.check_and_trigger_<name>_launch_modal(ctx) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 – View
|
||||
|
||||
Create `app/src/workspace/view/<name>_launch_modal/mod.rs`:
|
||||
|
||||
```rust
|
||||
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
|
||||
|
||||
```rust
|
||||
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( // 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<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://`:
|
||||
|
||||
```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 <name>_launch_modal;
|
||||
|
||||
// Import
|
||||
use crate::workspace::view::<name>_launch_modal::{<Name>LaunchModal, <Name>LaunchModalEvent};
|
||||
|
||||
// Struct field
|
||||
<name>_launch_modal: ViewHandle<<Name>LaunchModal>,
|
||||
|
||||
// In Workspace::new()
|
||||
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);
|
||||
});
|
||||
|
||||
// In struct initialization
|
||||
<name>_launch_modal: <name>_launch_view,
|
||||
|
||||
// In OneTimeModalModel subscription handler
|
||||
} else if model_ref.is_<name>_launch_modal_open() {
|
||||
me.focus_<name>_launch_modal(ctx);
|
||||
|
||||
// In View::render (inside the should_show_modal block)
|
||||
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:
|
||||
|
||||
```rust
|
||||
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`
|
||||
|
||||
```rust
|
||||
// In pub fn init()
|
||||
view::<name>_launch_modal::init(app);
|
||||
|
||||
// In debug bindings block
|
||||
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`:
|
||||
|
||||
```rust
|
||||
/// Open the <Name> Launch Modal (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
Open<Name>LaunchModal,
|
||||
/// Reset the <name> launch modal dismissed state (for debugging)
|
||||
#[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`:
|
||||
|
||||
```rust
|
||||
#[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 |
|
||||
Reference in New Issue
Block a user