Add targeted Factories launch feature-intro popover (APP-5648) - #15588
Add targeted Factories launch feature-intro popover (APP-5648)#15588warp-agent-staging[bot] wants to merge 10 commits into
Conversation
Reuses the existing bottom-right, non-blocking FeatureIntroModal infrastructure (data-driven via FEATURE_INTROS) to announce Warp Factories to a narrow, server-managed cohort and drive them to a sales/booking flow instead of the PLG waitlist. - New FeatureIntro entry gated by a new eligibility check (FeatureIntro.eligible), so an intro a user isn't yet targeted for is skipped without consuming its one-time-seen slot. Backed by a new FeatureFlag::FactoriesLaunchModal set by the corresponding new ServerExperiment (Control/Experiment arms), which the server admin tooling can enroll by individual email or company domain with no client release (see the paired warp-server change). - The 'Get Early Access' CTA opens a server-configured booking URL (User.factoriesLaunchModalCtaUrl, fetched alongside experiments in the workspaces-metadata poll and cached on UserWorkspaces), never a client-compiled URL, and never /request-access. Falls back to Warp's Contact Sales page until fetched. - Adds FeatureIntroModal telemetry (Shown/Dismissed/CtaClicked), which this popover previously emitted none of. - Show-once-ever is inherited unchanged from the existing cloud-synced seen_feature_intro_ids setting. Placeholder hero banner included; a real marketing asset should replace app/assets/async/png/onboarding/factories_launch_intro_banner.png before this ships broadly.
|
This PR was generated with Warp. Comment |
…ed CTA, scoped AI gate - Finding 2: Re-run the feature-intro check when server experiments update (subscribe to ServerExperimentsEvent::ExperimentsUpdated) and whenever a higher-priority launch modal or the free-AI-removal notice is dismissed (maybe_check_and_trigger_feature_intro_modal), so a server-targeted intro isn't stuck unseen until the next full app restart. - Finding 3: FactoriesLaunch now requires winning an atomic, cross-device server claim (AuthClient::claim_feature_intro_impression) before it is actually shown. The seen marker is still set synchronously so it only shows at most once per device even while the claim is in flight; a lost claim resumes the lower-priority modal chain instead. - Finding 4: UserWorkspaces::has_validated_factories_launch_modal_cta_url() fails closed until the server delivers a real (non-fallback) CTA URL; FactoriesLaunch's eligibility now requires it alongside the feature flag. - Finding 5: Removed the blanket is_any_ai_enabled gate from the shared feature-intro check. It now lives only on CustomModelRouter's own eligibility; FactoriesLaunch is purely server-driven. Adds unit tests for the recheck triggers, the server-claim gating (mocked AuthClient), and the CTA-URL / AI-enabled eligibility gates.
…im, strict CTA URL validation - Finding 1: The globally-synced seen marker for a requires_server_claim intro is now written only once the claim outcome is known (won, or genuinely lost to another device via Ok(false)), never on a request error. A network failure or offline device no longer permanently suppresses the modal everywhere; it simply retries on the next recheck. Tracks the in-flight claim (pending_claim_intro_id) so a recheck that fires mid-request doesn't start a second concurrent claim. Adds a regression test for the error/offline path plus a successful retry. - Finding 2: UserWorkspaces::has_validated_factories_launch_modal_cta_url now requires a well-formed absolute https URL with a non-empty host, and rejects the Contact Sales fallback even with a trailing slash or surrounding whitespace, instead of a weak byte-inequality check in front of ctx.open_url. Covered by new empty/malformed/fallback-variant test cases.
…est-access) Round 3 review: the CTA URL gate compared only against the exact Contact Sales URL with query equality required, so query-string variants of Contact Sales and /request-access outright slipped through. Replace the byte-comparison against a single fallback constant with a reserved-path check on the normalized (trailing slash trimmed, lowercased) path, independent of host, query string, or fragment. Also trims the doc comment on the validation helper down to its own behavior instead of naming its caller.
No client-side behavior change (trim_end_matches('/') already strips
every trailing slash); adds //-suffixed cases to the reserved-path test
tables to match the server-side fix.
There was a problem hiding this comment.
Overview
Adds the targeted Factories launch modal (bottom-right feature intro, server-gated cohort, once-ever impression claim, view/click/dismiss telemetry) across this PR and warpdotdev/warp-server#16154. Implementation and review are complete; posting the items that need a human decision rather than an approval or rejection.
Concerns
- The booking URL is not set, and nothing ships until it is. No booking link was supplied and there is no Calendly/Chili-Piper integration in either repo, so the CTA destination is a server-configured value (
features.factories_launch_modal_cta_url) and eligibility fails closed against the Contact Sales fallback and/request-access. The modal shows to nobody until a realhttpsbooking URL is configured — decide the destination. - The initial cohort is not loaded. The ~325 paid domains live in a Google Sheet outside this repo, so the allowlist ships empty. Load them through the new admin page in the server PR when the list is ready.
- Lead recording and booked-call attribution are deferred. CTA clicks are logged with the authenticated user ID, but no Factory lead is created in a sales-actionable system and no booking attribution exists. Decide the lead sink and whether booked-call tracking lands before launch or after.
- Neither repo runs build/test coverage on an ordinary PR. warp-server skips those jobs until merge queue (
.github/workflows/ci.yml:104-105,138-139,153-154) and this repo has no Rust build/test workflow, so all suites were run locally. The new atomicity integration test executes for the first time in merge-queue CI.
Verdict
Checks: build pass (local), tests pass (local), CI green on what executes, visual proof present
Found: 0 critical, 0 important, 0 suggestions, 0 nits — all 11 review findings from four rounds are resolved; the above are decisions, not defects.
Responding as wilson: Open session · View in factory
Requester feedback: the body text was too long for the 340px card. Cut it to roughly half while keeping what a factory on Warp is and the Early Access incentive (hands-on implementation support + up to $10K in Factory usage).
Requester feedback: the two ideas in the body copy (what a factory on Warp is, and the Early Access incentive) ran together into a single paragraph. Render description as separate lines split on '\n' rather than relying on the text-shaping engine to honor an embedded newline within one text run, so each line renders in a tightly-spaced column with explicit, deterministic line breaks.
…er email Design pass (requester copy): - New body copy and a distinct promotional offer block (FeatureIntroOffer) rendered as an accent-tinted rounded container below the description, with "up to $10K" highlighted in bold, accent-colored text via Text::with_single_highlight rather than faked emphasis. - More vertical space between the offer block and the footer divider, applied only when an offer is present so CustomModelRouter (offer: None) renders exactly as before. Real booking URL: - The CTA now appends the signed-in user's email as Chili Piper's documented id= smart parameter for Round-Robin scheduling links, so the booking form prefills instead of asking again. Falls back to the untouched URL when no email is available or the URL doesn't parse. - Added a positive-path test with the exact real booking URL, since the eligibility gate had only ever been exercised against rejections.
with_email_id_prefill appended id= unconditionally, so a configured CTA URL that already carried an id pair ended up with two, leaving it ambiguous (and consumer-dependent) which one identified the guest. Strip any existing id pair before appending the signed-in user's email, preserving every other query pair.
There was a problem hiding this comment.
Overview
Design pass, the real Chili Piper booking URL, and CTA email prefill are in; the implement-review loop is closed. Posting the decisions that are yours, superseding the booking-URL item from my earlier review.
Concerns
- The CTA discloses a signed-in user's email to a third party in a URL. The prefill appends
id=<email>so users land on the booking form filled in, which is what was asked for, but that address becomes URL data subject to browser history and Chili Piper's request, logging and analytics chain. Nothing in this change establishes that the disclosure and its retention are approved for this use — get an explicit privacy call, or switch to an opaque one-time token or a vendor-side integration. - The prefilled flow has never been opened in a real browser. The sandbox has no browser, so unit tests prove the URL is constructed correctly but nothing proves Chili Piper honours
idor that the form actually prefills. Someone should click it once on staging before this goes to real users. - The card grew from ~342px to ~408px, about +19%. That is the requested longer copy plus the offer block's chrome, and it is roughly half the window height. Compact was a stated constraint, so confirm the new footprint is acceptable or say what should give.
- Still unresolved from before: the ~325 paid domains are not loaded, so the allowlist is empty and this shows to nobody; and CTA clicks are telemetried with the user ID but no sales-actionable lead record or booked-call attribution exists.
Verdict
Checks: build pass (local), tests pass (local), CI green on what executes, visual proof present
Found: 0 critical, 0 important, 0 suggestions, 0 nits — every review finding across five rounds is resolved. The above are decisions and outstanding validation, not defects. Confirmed safe to merge without exposure: the config change adds no domain, email, or default arm, so the modal cannot reach anyone while the allowlist stays empty.
Responding as wilson: Open session · View in factory
Requester feedback: the card read tight vertically. Reworked the whole stack rather than the two gaps named previously: - badge-to-title: 8px -> 12px - title-group-to-description-to-offer: 8px -> 16px each - body container top/bottom padding: 16/20px -> 20/24px - offer block's own padding: 8/10px -> 12px uniform - footer vertical padding: 12px -> 16px - description/offer line height: 1.2 -> 1.4 Every new value is gated on FeatureIntro.offer being Some, and reuses spacing already established elsewhere in this crate's launch modals (openwarp_launch_modal, orchestration_launch_modal, auto_handoff_sleep_modal all use 8/16/32-based scales), rather than inventing one-off pixel values. CustomModelRouter (offer: None) renders pixel-identical to before: nesting its badge+title pair one level deeper doesn't change any gap when every level uses the same 8px spacing. Card height grows from ~408px to ~456px in an 800px window (57%), well under the two-thirds ceiling.



Summary
Targeted Factories launch popover for a narrow, server-managed cohort of existing Warp users (APP-5648). Drives qualified users to a sales/booking flow instead of the PLG
/request-accesswaitlist. Companion server-side change: warpdotdev/warp-server#16154.Reuses the existing bottom-right, non-blocking
FeatureIntroModal(data-driven viaFEATURE_INTROS) rather than any of the centered one-time launch modals, per the design direction in the originating thread.Changes
FeatureIntroentry for the Factories launch, with copy from a designer pass on the earlier captures: a title, a one-paragraph description of what a factory on Warp is, and a separate promotional offer block ("Get hands-on implementation support and up to $10K in Factory usage during Early Access.") with "up to $10K" as the strongest visual emphasis in that line.FeatureIntroOffer(text+emphasis), an optional field onFeatureIntro.FeatureIntroModal::render_offerrenders it as its own accent-tinted, rounded container distinct from the plain description text above it, withemphasishighlighted viaText::with_single_highlight(bold + the theme's accent color) rather than faked with capitalization or symbols.offer: NoneonCustomModelRouterkeeps that entry rendering exactly as before.openwarp_launch_modal,orchestration_launch_modal,auto_handoff_sleep_modalall use 8/16/32-based scales) rather than inventing one-off pixel values, and is gated onoffer.is_some()soCustomModelRouterrenders pixel-identical to before — nesting its badge+title pair one level deeper doesn't change any gap when every level uses the same 8px spacing. Card height grew from ~408px to ~456px in an 800px window (57%, well under the two-thirds ceiling I was asked to stay under).FeatureIntroModal::render_descriptionsplitsdescriptionon\nand renders each line as its ownTextelement in a tightly-spaced column (4px gap, not a full paragraph gap); this entry's current copy is a single paragraph and doesn't use it, but the capability stays available for a future multi-line description.PrimaryTheme(app/src/view_components/action_button.rs) — the same theme every other primary button in the app uses, includingCustomModelRouter's CTA, which reuses this exact same button view. Its color is the active theme's accent color, not a value chosen for this entry; pergui-ui-guidelines, shared button themes shouldn't be modified unilaterally for one feature. The same accent token is what the offer's emphasized text now uses too, so the two are visually consistent with each other and with the rest of the app.User.factoriesLaunchModalCtaUrl) with the signed-in user's email appended asid=, Chili Piper's documented smart parameter for identifying/prefilling a guest on a Round-Robin scheduling link (FeatureIntroModal::with_email_id_prefill). Falls back to the untouched URL when no email is available (anonymous user) or the URL doesn't parse.FeatureIntro.eligiblegate: an intro a user isn't currently targeted for is skipped without consuming its one-time-seen slot, so it can still show later once the user becomes eligible.OneTimeModalModelre-runs this check whenever server experiments update or a higher-priority launch modal / the free-AI-removal notice is dismissed, instead of only at startup.FeatureFlag::FactoriesLaunchModal, set by a newServerExperiment(FactoriesLaunchModalControl/Experiment) — enrollment happens entirely server-side via the paired warp-server PR's dedicated admin tooling.httpsURL and doesn't resolve to a reserved destination —/contact-sales(this validation's own fallback) or/request-access— checked on the normalized path regardless of host, query string, or fragment (UserWorkspaces::has_validated_factories_launch_modal_cta_url). The generic "AI enabled" gate that used to apply to every feature intro now lives only onCustomModelRouter's own eligibility.FeatureIntro.requires_server_claim(true only for FactoriesLaunch): before actually showing such an intro, the client wins an atomic, cross-device claim (AuthClient::claim_feature_intro_impression, backed by the companion server PR) so two devices can't both show it. The globally-synced seen marker is written only once the claim outcome is known — on a win, or a genuine loss to another device — never on a request error, so an offline device or a transient failure retries later instead of permanently losing its only impression. An in-flight claim is tracked so a recheck firing mid-request can't start a second concurrent claim.FeatureIntroModaltelemetry (Shown/Dismissed/CtaClicked).Still needs to be set before enabling for real users
Verification
cargo check -p warp --lib,cargo build -p warp --lib— clean.cargo test -p warp --lib workspace:: server::server_api:: workspaces::user_workspaces:: update_manager::— all pass (the one unrelated pre-existing failure,ambient_agent_headers_for_task_overrides_existing_cloud_agent_header, fails identically ondevelopdue to no network access to thensccredential tool in this sandbox)../script/formatandcargo clippy -p warp --lib --tests -- -D warnings(alsowarp_server_client,warp_graphql) — clean.ExperimentsUpdated/higher-priority-dismissal recheck triggers, the CTA-URL validation (malformed values, reserved-destination regression cases for/contact-salesand/request-accesswith query-string/fragment/case variants), the AI-enabled gate scoped toCustomModelRouteronly, andwith_email_id_prefill(appendsid=to a bare or already-querystring'd URL, leaves the URL untouched for no/empty email or an unparseable URL).has_validated_factories_launch_modal_cta_url()andFactoriesLaunch.eligibleboth fliptruewith the exacthttps://warp-dev.chilipiper.com/round-robin/factories-warp-introvalue now configured server-side (see the companion PR for the matching server-side positive-path test and the test that reads the actual shipped YAML configs).FeatureIntro.offer: NoneonCustomModelRouteris unaffected by the spacing pass by building this PR's merge-base commit in a separate worktree and comparing itsCustomModelRoutercard against the current branch's: identical title, description, icon, CTA, no offer block, and the same badge/title/description/footer proportions in both.id=email prefill by observing an actual opened browser: this sandbox has no browser orxdg-openhandler installed, soctx.open_urlhas nowhere to hand off to. Verified the URL-construction logic itself via the unit tests above instead.Computer-use video recordings (1)
Factories launch popover flow (spacing pass): Opening the Command Palette, running "[Debug] Open Feature Intro Modal", observing the card with the reworked vertical rhythm, clicking its "Get Early Access" CTA, and confirming it disappears and does not reappear.
Computer-use screenshots (4)
The card over a populated terminal pane (real command output scrolled up behind it), clipping the right edge of that content — not the empty "New session" pane shown in earlier rounds.
The same card overlapping an active agent conversation, clipping the right edge of the agent's response — the surface a lot of users actually live in.
Close-up confirming the reworked rhythm: badge separated from the title, title separated from the body, the offer block's comfortable internal padding, and visible gaps between the offer block, the divider, and the CTA button.
After clicking the close button, the popover is fully dismissed and does not return.