Skip to content

Commit b14c2ab

Browse files
malmsteinDavid Gonzalezclaude
authored
Duck.ai: + menu in omnibar and fire button in the chat input (#8705)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1214157224317277/task/1215159003631560?focus=true ### Description Last piece of the iOS Ship Review nav model for Duck.ai. Two coordinated changes, both scoped to Duck.ai fullscreen context (no change anywhere else): 1. **Omnibar fire → "+" menu.** In `ViewMode.DuckAI` the omnibar's fire icon is replaced by a "+" that opens a popup menu. The swap is derived from the view mode at render time (no separate state flag that can drift), and `tabsMenu` follows whichever of fire/+ is visible via a barrier. 2. **Fire button moves into the chat input.** In a Duck.ai chat (bottom bar) the fire button sits at the leading edge of the input row, outside the card so the card's rounded corners don't clip it. The trailing in-widget fire is hidden there so the user only ever sees one fire affordance, and the leading fire hides while the input is focused. **The "+" popup menu** (`popup_chat_menu.xml`, shown anchored via the shared `PopupMenu.showAnchoredView` so it positions above/below depending on whether the omnibar is at the top or bottom): | Item | Action | |---|---| | New Chat | `openNewDuckChat` → `NativeAction.NEW_CHAT` | | New Voice Chat | `duckChat.openVoiceDuckChat()` | | New Tab | `launchNewTab` (fresh NTP) | | New Fire Tab | `launchNewTab` (same as New Tab for now) | **New Fire Tab** is only shown when `FireModeAvailability.isAvailable()` is true (feature flag + WebView MultiProfile support), re-checked each time the menu opens, so we don't surface it to users who can't use Fire Mode. Outside fullscreen mode (legacy Intent path), nothing changes — the omnibar never enters `ViewMode.DuckAI`. Stacks conceptually on #8699 (toggle removal + back arrow) and #8700 (new-tab navigation), but is independent and lands on its own. ### Steps to test this PR _Omnibar fire → +_ - [x] On a normal browser tab the omnibar shows the fire icon (unchanged). - [x] Open a Duck.ai chat. The omnibar shows **+** where fire was; the fire icon is gone; the tabs button stays correctly positioned next to it. - [x] Switch back to a browser tab — fire returns, + is gone. Confirm + never appears on NTP or in custom tabs. _+ popup menu_ - [x] Tap **+** in a Duck.ai chat. A popup opens anchored to the + (above it when the omnibar is at the bottom, below when at the top). - [x] **New Chat** starts a fresh chat. - [x] **New Voice Chat** opens voice mode. - [x] **New Tab** opens a fresh New Tab Page. - [ ] **New Fire Tab** opens a fresh New Tab Page (same as New Tab for now). _New Fire Tab gating_ - [ ] With Fire Mode available (flag on + supported WebView), **New Fire Tab** is present. - [x] With Fire Mode unavailable (flag off or unsupported WebView), **New Fire Tab** is hidden and the rest of the menu is intact. _Fire button in the chat input_ - [x] In a Duck.ai chat (bottom bar), a fire icon sits at the leading edge of the input row, fully visible (not clipped by the card corners). - [x] The trailing in-widget fire (next to tabs/menu) is not shown in chat. - [x] Tap the leading fire — the fire / clear-data dialog opens, same as the omnibar fire. - [x] Focus the input — the leading fire hides; defocus — it reappears. - [x] On a browser tab, the trailing fire is shown and the leading fire is hidden (unchanged). _Legacy mode (fullscreen mode off)_ - [ ] Disable the Duck.ai fullscreen-mode flag. No UI changes anywhere — Duck.ai opens via the legacy Intent path and the omnibar never enters DuckAI view mode. ### UI changes | Before | After | | ------ | ----- | !(Upload before screenshot)|(Upload after screenshot)| --------- Co-authored-by: David Gonzalez <malmstein@Davids-MacBook-Pro.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9d2fa89 commit b14c2ab

15 files changed

Lines changed: 398 additions & 27 deletions

File tree

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,11 @@ import com.duckduckgo.browser.ui.browsermenu.BrowserMenuBottomSheet
273273
import com.duckduckgo.browser.ui.browsermenu.VpnMenuState
274274
import com.duckduckgo.browser.ui.newtab.hatch.NewTabReturnHatchView
275275
import com.duckduckgo.browsermode.api.BrowserMode
276+
import com.duckduckgo.browsermode.api.FireModeAvailability
276277
import com.duckduckgo.browsermode.api.WebViewModeInitializer
277278
import com.duckduckgo.common.ui.DuckDuckGoActivity
278279
import com.duckduckgo.common.ui.DuckDuckGoFragment
280+
import com.duckduckgo.common.ui.menu.PopupMenu
279281
import com.duckduckgo.common.ui.store.BrowserAppTheme
280282
import com.duckduckgo.common.ui.tabs.SwipingTabsFeatureProvider
281283
import com.duckduckgo.common.ui.view.DaxDialog
@@ -634,6 +636,9 @@ class BrowserTabFragment :
634636
@Inject
635637
lateinit var duckChat: DuckChat
636638

639+
@Inject
640+
lateinit var fireModeAvailability: FireModeAvailability
641+
637642
@Inject
638643
lateinit var duckAiFeatureState: DuckAiFeatureState
639644

@@ -789,6 +794,24 @@ class BrowserTabFragment :
789794
private val browserActivity
790795
get() = activity as? BrowserActivity
791796

797+
// Duck.ai "+" popup menu, anchored to the omnibar + button (see popup_chat_menu.xml).
798+
private val chatMenuPopup by lazy {
799+
PopupMenu(layoutInflater, com.duckduckgo.duckchat.impl.R.layout.popup_chat_menu).apply {
800+
onMenuItemClicked(contentView.findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewChat)) {
801+
viewModel.openNewDuckChat(omnibar.viewMode)
802+
}
803+
onMenuItemClicked(contentView.findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewVoiceChat)) {
804+
duckChat.openVoiceDuckChat()
805+
}
806+
onMenuItemClicked(contentView.findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewTab)) {
807+
browserActivity?.launchNewTab()
808+
}
809+
onMenuItemClicked(contentView.findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewFireTab)) {
810+
browserActivity?.launchNewTab()
811+
}
812+
}
813+
}
814+
792815
private var webView: DuckDuckGoWebView? = null
793816
private var isWebViewGestureInProgress = false
794817
private var lastTouchX: Float = 0f
@@ -1423,6 +1446,7 @@ class BrowserTabFragment :
14231446
),
14241447
)
14251448
},
1449+
onFireButtonPressed = { onFireButtonPressed() },
14261450
onVoiceSearchPressed = { isChatTab ->
14271451
val mode = if (isChatTab) VoiceSearchMode.DUCK_AI else VoiceSearchMode.SEARCH
14281452
webView?.onPause()
@@ -3768,6 +3792,17 @@ class BrowserTabFragment :
37683792
this@BrowserTabFragment.onFireButtonPressed()
37693793
}
37703794

3795+
override fun onPlusButtonPressed(anchor: View) {
3796+
val activity = activity ?: return
3797+
// Only offer "New Fire Tab" to users whose feature flag + WebView profile
3798+
// support actually allow Fire Mode. Re-checked on each open so a WebView/flag
3799+
// change is reflected without rebuilding the menu.
3800+
chatMenuPopup.contentView
3801+
.findViewById<View>(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewFireTab)
3802+
.isVisible = fireModeAvailability.isAvailable()
3803+
chatMenuPopup.showAnchoredView(activity, binding.rootView, anchor)
3804+
}
3805+
37713806
override fun onBrowserMenuPressed() {
37723807
onBrowserMenuButtonPressed()
37733808
}

app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ class NativeInputCallbacks(
7373
val onChatHistoryShortcutClicked: () -> Unit = {},
7474
val onClearAutocomplete: () -> Unit,
7575
val onStopTapped: () -> Unit,
76+
val onFireButtonPressed: () -> Unit = {},
7677
val onVoiceSearchPressed: (isChatTab: Boolean) -> Unit = {},
7778
val onCameraCaptureRequested: (ValueCallback<Array<Uri>>) -> Unit = {},
7879
val onFilePickerRequested: (ValueCallback<Array<Uri>>, List<String>) -> Unit = { _, _ -> },
@@ -541,6 +542,7 @@ class RealNativeInputManager @Inject constructor(
541542
) {
542543
widgetFrom(widgetView)?.apply {
543544
onStopTapped = callbacks.onStopTapped
545+
onFireButtonTapped = callbacks.onFireButtonPressed
544546
bindTabCount(lifecycleOwner, tabs.map { it.size })
545547
hideMainButtons()
546548
onAttachmentChooserStateChanged = { showing -> isPickingImage = showing }

app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ class Omnibar(
6969

7070
fun onFireButtonPressed()
7171

72+
fun onPlusButtonPressed(anchor: View)
73+
7274
fun onBrowserMenuPressed()
7375

7476
fun onPrivacyShieldPressed()

app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayout.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ class OmnibarLayout @JvmOverloads constructor(
166166
val showSpacer: Boolean,
167167
val showDuckSidebar: Boolean,
168168
val showDuckBack: Boolean,
169+
val isDuckAiMode: Boolean,
169170
)
170171

171172
@Inject
@@ -283,6 +284,7 @@ class OmnibarLayout @JvmOverloads constructor(
283284
override val omnibarTextInput: KeyboardAwareEditText by lazy { findViewById(R.id.omnibarTextInput) }
284285
internal val tabsMenu: TabSwitcherButton by lazy { findViewById(R.id.tabsMenu) }
285286
internal val fireIconMenu: FrameLayout by lazy { findViewById(R.id.fireIconMenu) }
287+
internal val plusIconMenu: FrameLayout by lazy { findViewById(R.id.plusIconMenu) }
286288
internal val aiChatMenu: View? by lazy { findViewById(R.id.aiChatIconMenu) }
287289
private val aiChatDivider: View by lazy { findViewById(R.id.verticalDivider) }
288290
internal val browserMenu: FrameLayout by lazy { findViewById(R.id.browserMenu) }
@@ -334,6 +336,7 @@ class OmnibarLayout @JvmOverloads constructor(
334336
addTarget(clearTextButton)
335337
addTarget(voiceSearchButton)
336338
addTarget(fireIconMenu)
339+
addTarget(plusIconMenu)
337340
addTarget(tabsMenu)
338341
addTarget(aiChatMenu)
339342
addTarget(browserMenu)
@@ -566,6 +569,9 @@ class OmnibarLayout @JvmOverloads constructor(
566569
}
567570
omnibarItemPressedListener?.onFireButtonPressed()
568571
}
572+
plusIconMenu.setOnClickListener {
573+
omnibarItemPressedListener?.onPlusButtonPressed(it)
574+
}
569575
browserMenu.setOnClickListener {
570576
omnibarItemPressedListener?.onBrowserMenuPressed()
571577
}
@@ -843,11 +849,13 @@ class OmnibarLayout @JvmOverloads constructor(
843849
showSpacer = viewState.showClearButton || viewState.showVoiceSearch,
844850
showDuckSidebar = viewState.showDuckAISidebar,
845851
showDuckBack = viewState.showDuckAISidebar,
852+
isDuckAiMode = viewState.viewMode is ViewMode.DuckAI,
846853
)
847854

848855
if (omnibarAnimationManager.isFeatureEnabled() && previousTransitionState != null &&
849856
(
850857
newTransitionState.showFireIcon != previousTransitionState?.showFireIcon ||
858+
newTransitionState.isDuckAiMode != previousTransitionState?.isDuckAiMode ||
851859
newTransitionState.showTabsMenu != previousTransitionState?.showTabsMenu ||
852860
newTransitionState.showBrowserMenu != previousTransitionState?.showBrowserMenu ||
853861
newTransitionState.showDuckSidebar != previousTransitionState?.showDuckSidebar
@@ -859,7 +867,11 @@ class OmnibarLayout @JvmOverloads constructor(
859867
clearTextButton.isVisible = viewState.showClearButton
860868
voiceSearchButton.isVisible = viewState.showVoiceSearch
861869
tabsMenu.isVisible = newTransitionState.showTabsMenu
862-
fireIconMenu.isVisible = newTransitionState.showFireIcon
870+
// The fire/+ slot is shared: when the user is in a Duck.ai chat the + icon takes
871+
// over from fire as the leading action. Driven by viewMode rather than a separate
872+
// state flag so it can't drift out of sync with other state-update paths.
873+
fireIconMenu.isVisible = newTransitionState.showFireIcon && !newTransitionState.isDuckAiMode
874+
plusIconMenu.isVisible = newTransitionState.showFireIcon && newTransitionState.isDuckAiMode
863875
browserMenu.isVisible = newTransitionState.showBrowserMenu
864876
browserMenuHighlight.isVisible = newTransitionState.showBrowserMenuHighlight
865877
aiChatMenu?.isVisible = newTransitionState.showChatMenu
Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
<?xml version="1.0" encoding="utf-8"?>
2-
<!--
1+
<?xml version="1.0" encoding="utf-8"?><!--
32
~ Copyright (c) 2026 DuckDuckGo
43
~
54
~ Licensed under the Apache License, Version 2.0 (the "License");
@@ -19,30 +18,57 @@
1918
android:id="@+id/inputModeBottomRoot"
2019
android:layout_width="match_parent"
2120
android:layout_height="wrap_content"
22-
android:background="?daxColorSurface"
23-
android:layout_gravity="bottom">
21+
android:layout_gravity="bottom"
22+
android:background="?daxColorSurface">
2423

25-
<!-- Negative bottom margin pulls the card down to account for the shadow -->
26-
<com.google.android.material.card.MaterialCardView
27-
android:id="@+id/inputModeWidgetCard"
24+
<LinearLayout
2825
android:layout_width="match_parent"
2926
android:layout_height="wrap_content"
30-
android:layout_marginStart="@dimen/keyline_2"
31-
android:layout_marginEnd="@dimen/keyline_2"
32-
android:layout_marginBottom="@dimen/keyline_2"
33-
android:layout_marginTop="@dimen/keyline_2"
34-
app:cardBackgroundColor="?attr/daxColorWindow"
35-
app:cardCornerRadius="26dp"
36-
app:cardElevation="3dp"
37-
app:cardUseCompatPadding="false">
27+
android:gravity="center_vertical"
28+
android:orientation="horizontal"
29+
android:paddingStart="@dimen/keyline_2"
30+
android:paddingEnd="@dimen/keyline_2">
3831

39-
<com.duckduckgo.duckchat.impl.ui.nativeinput.views.NativeInputModeWidget
40-
android:id="@+id/inputModeWidget"
41-
android:paddingStart="@dimen/keyline_1"
42-
android:paddingTop="@dimen/keyline_2"
43-
android:paddingEnd="@dimen/keyline_1"
44-
android:paddingBottom="@dimen/keyline_2"
45-
android:layout_width="match_parent"
46-
android:layout_height="wrap_content" />
47-
</com.google.android.material.card.MaterialCardView>
32+
<FrameLayout
33+
android:id="@+id/inputFieldFireIconMenu"
34+
android:layout_width="@dimen/toolbarIcon"
35+
android:layout_height="@dimen/toolbarIcon"
36+
android:layout_marginEnd="@dimen/keyline_2"
37+
android:background="@drawable/selectable_item_rounded_corner_background"
38+
android:visibility="gone">
39+
40+
<ImageView
41+
android:id="@+id/inputFieldFireIconImageview"
42+
android:layout_width="@dimen/bottomNavIcon"
43+
android:layout_height="@dimen/bottomNavIcon"
44+
android:layout_gravity="center"
45+
android:contentDescription="@string/fireMenu"
46+
android:scaleType="center"
47+
android:src="@drawable/ic_fire_24" />
48+
</FrameLayout>
49+
50+
<com.google.android.material.card.MaterialCardView
51+
android:id="@+id/inputModeWidgetCard"
52+
android:layout_width="0dp"
53+
android:layout_height="wrap_content"
54+
android:layout_marginTop="@dimen/keyline_2"
55+
android:layout_marginBottom="@dimen/keyline_2"
56+
android:layout_weight="1"
57+
app:cardBackgroundColor="?attr/daxColorWindow"
58+
app:cardCornerRadius="26dp"
59+
app:cardElevation="3dp"
60+
app:cardUseCompatPadding="false">
61+
62+
<com.duckduckgo.duckchat.impl.ui.nativeinput.views.NativeInputModeWidget
63+
android:id="@+id/inputModeWidget"
64+
android:layout_width="match_parent"
65+
android:layout_height="wrap_content"
66+
android:paddingStart="@dimen/keyline_1"
67+
android:paddingTop="@dimen/keyline_2"
68+
android:paddingEnd="@dimen/keyline_1"
69+
android:paddingBottom="@dimen/keyline_2" />
70+
71+
</com.google.android.material.card.MaterialCardView>
72+
73+
</LinearLayout>
4874
</com.duckduckgo.duckchat.impl.ui.nativeinput.views.NonCollapsingFrameLayout>

app/src/main/res/layout/view_omnibar.xml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,9 +496,17 @@
496496
android:layout_height="@dimen/toolbarIcon"
497497
app:layout_constraintBottom_toBottomOf="parent"
498498
app:layout_constraintEnd_toStartOf="@id/browserMenu"
499-
app:layout_constraintStart_toEndOf="@id/fireIconMenu"
499+
app:layout_constraintStart_toEndOf="@id/leadingIconBarrier"
500500
app:layout_constraintTop_toTopOf="parent" />
501501

502+
<androidx.constraintlayout.widget.Barrier
503+
android:id="@+id/leadingIconBarrier"
504+
android:layout_width="wrap_content"
505+
android:layout_height="wrap_content"
506+
app:barrierAllowsGoneWidgets="false"
507+
app:barrierDirection="end"
508+
app:constraint_referenced_ids="fireIconMenu,plusIconMenu" />
509+
502510
<FrameLayout
503511
android:id="@+id/browserMenu"
504512
android:layout_width="@dimen/toolbarIcon"
@@ -535,7 +543,6 @@
535543
android:layout_height="@dimen/toolbarIcon"
536544
android:background="@drawable/selectable_item_rounded_corner_background"
537545
app:layout_constraintBottom_toBottomOf="parent"
538-
app:layout_constraintEnd_toStartOf="@id/tabsMenu"
539546
app:layout_constraintStart_toStartOf="parent"
540547
app:layout_constraintTop_toTopOf="parent">
541548

@@ -549,6 +556,26 @@
549556
android:src="@drawable/ic_fire_24" />
550557
</FrameLayout>
551558

559+
<FrameLayout
560+
android:id="@+id/plusIconMenu"
561+
android:layout_width="@dimen/toolbarIcon"
562+
android:layout_height="@dimen/toolbarIcon"
563+
android:background="@drawable/selectable_item_rounded_corner_background"
564+
android:visibility="gone"
565+
app:layout_constraintBottom_toBottomOf="parent"
566+
app:layout_constraintStart_toStartOf="parent"
567+
app:layout_constraintTop_toTopOf="parent">
568+
569+
<ImageView
570+
android:id="@+id/plusIconImageView"
571+
android:layout_width="@dimen/bottomNavIcon"
572+
android:layout_height="@dimen/bottomNavIcon"
573+
android:layout_gravity="center"
574+
android:contentDescription="@string/duckChatPlusButtonContentDescription"
575+
android:scaleType="center"
576+
android:src="@drawable/ic_add_24" />
577+
</FrameLayout>
578+
552579
<Space
553580
android:layout_width="@dimen/omnibarCardExtendedMarginEnd"
554581
android:layout_height="match_parent"

browser/browser-ui/src/main/res/values/donottranslate.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,9 @@
3030
<string name="newTabReturnHatchTabClosed">Tab closed</string>
3131
<string name="newTabReturnHatchUndo">Undo</string>
3232
<string name="browserMenuChats">Chats</string>
33+
34+
<string name="chatMenuPopupNewChat">New Chat</string>
35+
<string name="chatMenuPopupNewVoiceChat">New Voice Chat</string>
36+
<string name="chatMenuPopupNewTab">New Tab</string>
37+
<string name="chatMenuPopupNewFireTab">New Fire Tab</string>
3338
</resources>

0 commit comments

Comments
 (0)