Make Duck.ai onboarding compatible with native input field#8888
Make Duck.ai onboarding compatible with native input field#8888lmac012 wants to merge 15 commits into
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
3abd833 to
dc7f3d4
Compare
| applyEnabled(clearTextButton, nonFireEnabled) | ||
| applyEnabled(duckAISidebar, nonFireEnabled) | ||
| applyEnabled(duckAIHeader, nonFireEnabled) | ||
| applyEnabled(duckAIFreePill, nonFireEnabled) |
There was a problem hiding this comment.
Not a blocker but something I wanted to consider - any ideas how we could make this more robust so that it doesn't regress in the future? Instead of disabling each view independently (and people contributing new UI elements would need to remember to do so), could we apply a mask with semi-transparent foreground on the whole container and capture clicks so that they are not passed down to children? Or something similar?
There was a problem hiding this comment.
Makes sense, I can look into it while working on the follow up task to hide duckAIFreePill entirely. As the requirements kept evolving, it wasn't clear we'd end up disabling everything, so I initially went with a simple yet flexible approach.
|
|
||
| browserNavigationBarIntegration.configureFireButtonHighlight(highlighted = viewState.fireButton.isHighlighted()) | ||
| browserNavigationBarIntegration.configureLockForOnboarding(locked = viewState.isOmnibarLockedForOnboarding) | ||
| nativeInputManager.setFireButtonSpotlightMode(viewState.fireButton.isHighlighted()) |
There was a problem hiding this comment.
We don't need to check viewState.isOmnibarLockedForOnboarding here?
There was a problem hiding this comment.
For the most part viewState.isOmnibarLockedForOnboarding is equal to viewState.fireButton.isHighlighted(). However, there is a tiny time window at the beginning before we start showing the fire button CTA + highlight, where we currently don't lock the native input field, but ideally we should, so I will look into improving this.
| backgroundRes = CommonR.drawable.bg_onboarding_end, | ||
| shownPixel = AppPixelName.ONBOARDING_DAX_CTA_SHOWN, | ||
| okPixel = AppPixelName.ONBOARDING_DAX_CTA_OK_BUTTON, | ||
| ctaPixelParam = "duck_ai_end_cta", |
There was a problem hiding this comment.
The original end CTA uses two different pixels for in-browser and NTP end CTAs, but here we use the same pixel for both. Should we use separate ones? Also, could you move it to a constant?
There was a problem hiding this comment.
Unlike the original CTA, the duck ai end CTA is only shown in the context of NTP. I'm ignoring InputScreen, since that path is going to be disabled anyway.
I will extract this to a constant (it's not great that other CTAs keep these constants in the statistics-api module, but we can prioritize consistency here).
| primaryCta = R.string.onboardingDuckAiEndCtaButton, | ||
| shownPixel = AppPixelName.ONBOARDING_DAX_CTA_SHOWN, | ||
| okPixel = AppPixelName.ONBOARDING_DAX_CTA_OK_BUTTON, | ||
| ctaPixelParam = "duck_ai_end_cta", |
|
Using back button on the Duck.ai demo screen breaks the flow: Screen_recording_20260616_155810.mp4 |
|
Thanks for the review @LukasPaczos, I pushed a few commits to address (most of) your feedback. For the remaining items (CTA spacing in landscape orientation, disabling views in bulk) - I propose dealing with them as follow-ups. The landscape issue seems to be shared with existing (reskinned) onboarding CTAs (but it is not as visible unless address bar is set to split). Ideally this should be solved adjusting the scrolling behavior (make CTAs scroll with the page content), but I'll have look if we can reduce the empty space below this particular CTA. The back button is definitely a blocker, thanks for catching that. Fortunately, the fix seems to be simple. |
| if (!isAdded) return false | ||
| // During the locked Duck.ai onboarding demo, don't navigate within the app — returning false | ||
| // lets BrowserActivity exit (close the app). The user progresses by tapping the fire button. | ||
| if (viewModel.isOmnibarLockedForOnboarding()) return false |
There was a problem hiding this comment.
This was flagged as a known issue for the initial experiment, but after the app closes and restarts here, we end up in an unlocked state, so user can interact with Duck.ai or even close it while the fire animation plays etc.
Longer term, I think this flow should move to the linear onboarding orchestrator, which forces the Duck.ai browser CTAs into a linear flow and handles aborting correctly (restarts from scratch).
Either way, since we already deemed this an edge case, it's not a blocker here.
|
btw. I tested with forced native input, as it's rolling out now: diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt
index 151e8b0c21..6043dee436 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt
@@ -142,6 +142,7 @@ interface DuckChatFeature {
* @return `true` when the Native Input Field should be used instead of the web-based input.
*/
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
+ @InternalAlwaysEnabled
fun nativeInputField(): Toggle |
a305231 to
94f4eb5
Compare
9acd1af to
bf0c9c4
Compare
| is DaxBubbleCta.DaxEndCta, | ||
| is DaxEndBrandDesignUpdateBubbleCta, | ||
| is DaxDuckAiEndBubbleCta, | ||
| is DaxDuckAiEndBrandDesignUpdateBubbleCta, |
There was a problem hiding this comment.
Missing final dialog press metric
Low Severity
Tapping High five! on the native-input DaxDuckAiEnd* home bubble fires the standard OK pixel via onUserClickCtaOkButton, but never calls duckAiOnboardingExperimentMetrics.fireFinalDialogPressed(). The legacy input-screen path still reports that metric through onDuckAiEndCtaInputScreenResult → onDuckAiEndCtaInteraction.
Reviewed by Cursor Bugbot for commit 94f4eb5. Configure here.
94f4eb5 to
dd85114
Compare
| dismissedCtaDao.insert(DismissedCta(CtaId.DAX_DUCK_AI_END)) | ||
| completeStageIfDaxOnboardingCompleted() | ||
| if (canSendShownPixel(onboardingStore, DUCK_AI_END_CTA_PIXEL_PARAM)) { | ||
| val journey = addCtaToHistory(onboardingStore, appInstallStore, DUCK_AI_END_CTA_PIXEL_PARAM) |
There was a problem hiding this comment.
Missing final dialog pressed metric
Medium Severity
Tapping High five on the native-input DaxDuckAiEndBubbleCta / brand-design end bubble fires the standard OK pixel via onUserClickCtaOkButton, but never calls duckAiOnboardingExperimentMetrics.fireFinalDialogPressed(). The legacy input-screen path still sends that metric through onDuckAiEndCtaInteraction, so Duck.ai onboarding experiment conversion data is incomplete for users on the new NTP bubble flow.
Reviewed by Cursor Bugbot for commit dd85114. Configure here.
malmstein
left a comment
There was a problem hiding this comment.
This change not only affects Onboarding, because you are setting a “state” that is computed outside onboarding too.
I’d like this to either use the existing path to update the input field state or create a separation that only affects onboarding.
The suggestion to disabling views in bulk needs to go through O-J since we are about refactor much of the tech debt added.
| * [EnabledState.DISABLED] none, [EnabledState.FIRE_BUTTON_SPOTLIGHT] only the fire button | ||
| * (highlighted with a pulse). | ||
| */ | ||
| enum class EnabledState { ENABLED, DISABLED, FIRE_BUTTON_SPOTLIGHT } |
There was a problem hiding this comment.
why is this here? There’s a NativeInputState that represents the current state. Please use the existing pattern, there’s no need to duplicate it for the sake of onboarding.
| } | ||
| } | ||
|
|
||
| override fun setEnabledState(state: EnabledState) { |
There was a problem hiding this comment.
same as above, there’s a NativeInputState and a NativeInputStatePublisher that are in charge of setting the state of the input field. What happens here is exclusively related to onboarding and pulse animations, so I’d prefer that you either use the existing path to change the input field state or create a proper separation that only affects the Onboarding path.
Calling this enabledState is not correct.
Fixes a regression in #8888. The PR introduced a new end CTA for DUck.ai flow, so the custom AI copy needs to move to it. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Onboarding copy routing only; no auth, data, or core browser behavior changes. > > **Overview** > Fixes a regression where **custom AI onboarding** end messaging lived on the generic **Dax end** bubble after a separate **Duck AI end** CTA was added. > > **`DaxDuckAiEndBrandDesignUpdateBubbleCta`** now picks the description from `onboardingStore.isCustomAiOnboardingFlow()`: custom flow uses `onboardingEndCustomAiFlowDaxDialogDescription`, otherwise the standard Duck AI end string. > > **`DaxEndBrandDesignUpdateBubbleCta`** is simplified back to always using `onboardingEndDaxDialogDescription`, with the custom-flow branch and `decorateDescription` AI icon logic removed from that class. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b8b0711. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
When DuckChatFeature.nativeInputField() is enabled the legacy InputScreenActivity auto-launch never fires (RealDuckChat forces showInputScreen=false), so after the in-context fire op the user lands on the NTP with the duck.ai onboarding stuck — no DAX_DUCK_AI_END CTA, no follow-up Privacy Pro CTA, stage frozen at DAX_ONBOARDING. Add two bubble-CTA variants (legacy DaxDuckAiEndBubbleCta + brand-design DaxDuckAiEndBrandDesignUpdateBubbleCta) and surface them from CtaViewModel.getHomeCta when the input screen is not enabled. The standard onCtaShown lifecycle marks the CTA as shown (markAsReadOnShow=true) and now also fires the FinalDialogImpression metric and completes the onboarding stage — mirroring what prepareAndMarkDuckAiEndCtaForInputScreen does for the legacy path. The legacy InputScreenActivity path is untouched (suppression preserved when isInputScreenEnabled). Both variants apply the same ic_ai_chat_16 icon-suffix the InputScreenFragment renderer uses today. The brand-design variant hooks decorateDescription; the legacy variant wraps onTypingAnimationFinished to overwrite the typed text once the animation completes (TypeAnimationTextView types plain strings only).
BrowserTabViewModel.onDaxBubbleCtaOkButtonClicked has an explicit when branch for DaxBubbleCta.DaxEndCta / DaxEndBrandDesignUpdateBubbleCta that calls refreshCta() and pipes the result back into ctaViewState — which is what actually swaps the bubble view off the screen. The two new Duck.AI end bubble CTAs fell into the else branch, so tapping High Five fired the OK pixel but left the bubble visible. Add both to the existing branch.
When nativeInputField is on, the fire button lives in the bottom widget, not the omnibar, so the onboarding pulse no longer landed on it. Add NativeInputManager.setFireButtonSpotlightMode to pulse the widget's leading fire button, forward the highlight from BrowserTabFragment, and stop the omnibar from pulsing its hidden fire icon in Duck.AI mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The omnibar is already locked to FIRE_BUTTON_ONLY during the CTA; applyEnabledState just wasn't dimming/disabling the + icon that replaces the fire button in Duck.AI native-input mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add NativeInputWidget.setInteractionLocked, which dims the (transparent) widget and swallows touches so only the leading fire button — a sibling outside the widget — stays usable. Dimming the widget rather than its parent card keeps the input bar colour-uniform with the page. Driven from setFireButtonSpotlightMode alongside the pulse. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Duck.AI end CTA bubble lives inside the NTP (newTabContent), which NativeInputLayoutCoordinator.configureContentOffset already offsets below the native input widget — except it suppresses that offset for logo-only NTPs to keep the logo centered. Skip the suppression while an onboarding CTA bubble is visible so the bubble clears the widget in that case too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Duck.AI "Free plan" pill is shown and click-wired by NativeInputOmnibarController, so the onboarding lock's applyEnabledState never disabled it and it still opened the subscriptions screen. Disable and dim it alongside the other non-fire omnibar buttons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…arding The "X" that closes Duck.AI (duckAiBack) wasn't in applyEnabledState, so the onboarding lock left it tappable. Disable and dim it alongside the other non-fire omnibar buttons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote setFireButtonSpotlightMode to setEnabledState(EnabledState) so the widget models all three onboarding states like the omnibar/nav bar: ENABLED, DISABLED (locked before the CTA), and FIRE_BUTTON_SPOTLIGHT. Previously the widget only locked once the fire CTA appeared, leaving it interactive during the earlier isOmnibarLockedForOnboarding window. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
During the full-screen Duck.ai onboarding demo the omnibar/widget are locked (isOmnibarLockedForOnboarding), but the system Back button wasn't, so pressing it tore down the native widget via hideNativeInput() and navigated within the app, leaving the user on a permanently-locked omnibar. While locked, return false from onBackPressed (before hideNativeInput) so BrowserActivity's existing unhandled-back path exits the root activity — closing the app — without tearing the widget down. The user progresses by tapping the fire button. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The duck_ai_end_cta / duck_ai_fire_button_cta pixel-param strings were hardcoded across the CTA classes and CtaViewModel. Move them into Pixel.PixelValues (DUCK_AI_END_CTA, DUCK_AI_FIRE_BUTTON_CTA) for consistency with the other CTA params, and reference the constants everywhere. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes a regression in #8888. The PR introduced a new end CTA for DUck.ai flow, so the custom AI copy needs to move to it. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Onboarding copy routing only; no auth, data, or core browser behavior changes. > > **Overview** > Fixes a regression where **custom AI onboarding** end messaging lived on the generic **Dax end** bubble after a separate **Duck AI end** CTA was added. > > **`DaxDuckAiEndBrandDesignUpdateBubbleCta`** now picks the description from `onboardingStore.isCustomAiOnboardingFlow()`: custom flow uses `onboardingEndCustomAiFlowDaxDialogDescription`, otherwise the standard Duck AI end string. > > **`DaxEndBrandDesignUpdateBubbleCta`** is simplified back to always using `onboardingEndDaxDialogDescription`, with the custom-flow branch and `decorateDescription` AI icon logic removed from that class. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b8b0711. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
16cf7c1 to
ae3eb36
Compare
Relocate PulseAnimation (and its ic_circle_pulse_blue drawable) from :app to :browser-ui so it can be shared with duckchat-impl. Pure move with no behavior change; updates the three import sites (OmnibarLayout, BrowserNavigationBarView, NativeInputManager). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the imperative NativeInputManager.setEnabledState(EnabledState) API with two NativeInputState fields (interactionLocked, fireButtonHighlighted) that the widget renders, per review feedback to use the existing NativeInputState pattern. NativeInputManager now only publishes the onboarding signal via configureLockForOnboarding/configureFireButtonHighlight; the widget applies the body lock and the leading fire-button dim/pulse (PulseAnimation, now in :browser-ui). BrowserTabFragment forwards the two viewState booleans, mirroring the nav-bar integration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| updateBackButtons(state) | ||
| updateFireButtonVisibility(state) | ||
| updateDuckAiFireButtonHighlight(state) | ||
| setInteractionLocked(state.interactionLocked) |
There was a problem hiding this comment.
Fire CTA pulse after focus
Medium Severity
In applyState, updateFireButtonVisibility and updateDuckAiFireButtonHighlight run before setInteractionLocked. The leading fire icon is hidden while the input has focus, and setInteractionLocked(true) clears focus afterward without re-running those updates, so the fire control can stay hidden and the onboarding pulse never starts until another state emission.
Reviewed by Cursor Bugbot for commit 888423c. Configure here.
Models the native input's interaction-lock state as Unlocked / Locked / LockedExceptDuckAiFireButton instead of a boolean whose fire-button-exemption was derived in the widget. The fire-button pulse stays on its own duckAiFireButtonHighlighted flag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a49e483 to
5486113
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5486113. Configure here.
| ctaPixelParam = Pixel.PixelValues.DUCK_AI_END_CTA, | ||
| onboardingStore = onboardingStore, | ||
| appInstallStore = appInstallStore, | ||
| ) { |
There was a problem hiding this comment.
Custom AI end copy missing
Low Severity
When brand-design update is off, DaxDuckAiEndBubbleCta always uses the generic end description. DaxDuckAiEndBrandDesignUpdateBubbleCta switches to onboardingEndCustomAiFlowDaxDialogDescription for custom AI onboarding, so those users on the legacy bubble see the wrong end-CTA copy.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5486113. Configure here.




Task/Issue URL: https://app.asana.com/1/137249556945/project/1205648422731273/task/1215660165459344?focus=true
Tech Design URL (if applicable):
Description
The Duck.ai onboarding flow (the in-context fire-button CTA and the end CTA) was built for the
classic omnibar/input-screen layout. When the
duckChat.nativeInputFieldfeature is enabled, thefire button and chat controls move into the bottom native-input widget and the omnibar shows
different chrome, which broke the onboarding experience. This PR makes the flow work correctly in
that mode.
Changes:
nativeInputFieldon, the legacyInputScreenActivitypathno longer fires, so the
DAX_DUCK_AI_ENDCTA is rendered as a home-tab bubble on the NTP instead(legacy + brand-design variants), and dismisses correctly on the "High Five" tap.
onboarding pulse is applied there instead of the (now-hidden) omnibar fire icon
(
setFireButtonSpotlightMode).made non-interactive except for the fire button, and the omnibar controls that aren't covered by
the existing onboarding lock are disabled too: the new-chat + icon, the Free plan pill, and
the Duck.ai close (X) button.
Legacy behaviour (with
nativeInputFieldoff) is unchanged.Steps to test this PR
Apply this diff to make the duck.ai onboarding path available. The native input field is already
enabled in internal builds, so no other change is needed.
override suspend fun enroll(): DuckAiOnboardingExperimentVariant? = withContext(dispatcherProvider.io()) { if (!arePrerequisitesMet()) return@withContext null + return@withContext DuckAiOnboardingExperimentVariant.TREATMENT_WITH_SEARCH_DEFAULT + val toggle = extendedOnboardingFeatureToggles.onboardingDuckAiExperimentMay26() private fun arePrerequisitesMet(): Boolean = !appBuildConfig.isDefaultVariantForced && browserConfig.singleTabFireDialog().isEnabled() && - !onboardingBrandDesignUpdateToggles.brandDesignUpdate().isEnabled() && !deviceInfo.isTablet()Fire-button CTA
End CTA
Regression
duckChat.nativeInputFieldflag is disabled, then do a clean installUI changes
Note
Medium Risk
Touches onboarding gating, back navigation, and native-input interaction during a guided flow; legacy paths are feature-flagged but the new bubble and lock wiring spans browser, CTA, and duckchat modules.
Overview
Makes Duck.ai onboarding work when the native input field is on instead of the legacy input screen.
End CTA on the NTP: If
DuckAiFeatureState.showInputScreenis false,DAX_DUCK_AI_ENDis shown as home-tab bubbles (DaxDuckAiEndBubbleCta/ brand-design variant) with the same impression and completion side effects as the input-screen path; custom-AI copy moves off genericDaxEndBrandDesignUpdateBubbleCta. Legacy input-screen mode still suppresses home CTAs until that flow runs.Locked demo UX: Browser view state drives
NativeInputState.InteractionLockand fire-button highlight on the widget (full lock vs lock except leading fire). Back is blocked while omnibar onboarding lock is active. Omnibar also disables +, Free plan, and Duck.ai back during lock; fire pulse targets visible fire chrome in Duck.ai/native-input mode.Layout:
isLogoOnlytreats visible onboarding CTA bubbles as non-logo-only so NTP content offsets below the taller widget and bubbles stay visible.PulseAnimationmoves tobrowser-ui; pixel CTA params centralize inPixel.PixelValues.Reviewed by Cursor Bugbot for commit 5486113. Bugbot is set up for automated code reviews on this repo. Configure here.