Files
galaxy/.agents/skills/create-launch-modal/SKILL.md
T

407 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 |