From adddd0d2405d484fc23ff1e680a6560684b3b898 Mon Sep 17 00:00:00 2001 From: David Gonzalez Date: Thu, 2 Jul 2026 10:56:15 +0200 Subject: [PATCH 1/5] UTI: Add to Contextual Sheet --- .../contextual/DuckChatContextualFragment.kt | 2 +- .../fragment_contextual_duck_ai_native.xml | 390 ++++++++++++++++++ 2 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai_native.xml diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt index 35d00f0c98e1..32a26c81af10 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt @@ -120,7 +120,7 @@ import javax.inject.Named @InjectWith(FragmentScope::class) class DuckChatContextualFragment : - DuckDuckGoFragment(R.layout.fragment_contextual_duck_ai), + DuckDuckGoFragment(R.layout.fragment_contextual_duck_ai_native), DownloadConfirmationDialogListener { @Inject diff --git a/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai_native.xml b/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai_native.xml new file mode 100644 index 000000000000..70e796be591c --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai_native.xml @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 79adbe5b20be8421292565c338eb7e074227e3c3 Mon Sep 17 00:00:00 2001 From: David Gonzalez Date: Thu, 2 Jul 2026 14:24:19 +0200 Subject: [PATCH 2/5] first version of UTI in contextual --- .../ContextualNativeInputManager.kt | 46 +++++++- .../contextual/DuckChatContextualFragment.kt | 57 ++++++---- .../contextual/DuckChatContextualViewModel.kt | 48 ++++++-- .../DuckChatContextualViewModelTest.kt | 60 ++++++++++ .../RealContextualNativeInputManagerTest.kt | 105 ++++++++++++++++++ 5 files changed, 284 insertions(+), 32 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt index f42e7fd9a686..64add6fc26fd 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt @@ -40,6 +40,20 @@ import org.json.JSONArray import org.json.JSONObject import javax.inject.Inject +/** + * A prompt submitted from the unified input widget while the contextual sheet is in its initial + * (INPUT) state. Carries the widget's current model/reasoning/tool/attachment selections so the + * new chat starts with everything the user configured. + */ +data class NativeInputPrompt( + val prompt: String, + val modelId: String?, + val reasoningEffort: String?, + val selectedTool: String?, + val imagesJson: JSONArray?, + val filesJson: JSONArray?, +) + interface ContextualNativeInputManager { fun init( tabId: String, @@ -51,6 +65,9 @@ interface ContextualNativeInputManager { onSearchSubmitted: (String) -> Unit, onCameraCaptureRequested: (ValueCallback>) -> Unit = {}, onFilePickerRequested: (ValueCallback>, List) -> Unit = { _, _ -> }, + // Invoked when the widget submits a prompt while the sheet is in INPUT mode: starts a new chat. + // WEBVIEW-mode submissions keep going through the in-chat JS event path (see setupWidget). + onNewChatPromptSubmitted: (NativeInputPrompt) -> Unit = {}, ) fun onWebViewMode() @@ -90,13 +107,14 @@ class RealContextualNativeInputManager @Inject constructor( onSearchSubmitted: (String) -> Unit, onCameraCaptureRequested: (ValueCallback>) -> Unit, onFilePickerRequested: (ValueCallback>, List) -> Unit, + onNewChatPromptSubmitted: (NativeInputPrompt) -> Unit, ) { this.card = card this.jsMessaging = jsMessaging this.widget = widget applyCardShape(card) - setupWidget(tabId, widget, chatIdFlow, onSearchSubmitted, onCameraCaptureRequested, onFilePickerRequested) + setupWidget(tabId, widget, chatIdFlow, onSearchSubmitted, onCameraCaptureRequested, onFilePickerRequested, onNewChatPromptSubmitted) observeNativeInputSetting(lifecycleOwner) } @@ -125,9 +143,15 @@ class RealContextualNativeInputManager @Inject constructor( override fun onInputMode() { lastMode = Mode.INPUT - card?.gone() - // INPUT mode is a new chat: restore the picker - if (isNativeInputEnabled) modelPickerEnabled.value = true + if (isNativeInputEnabled) { + // The unified input widget is the composer for the initial sheet. + card?.show() + // INPUT mode is a new chat: restore the picker so the user can pick a model before starting. + modelPickerEnabled.value = true + } else { + // Flag off: the legacy EditText composer is shown instead, so keep the widget card hidden. + card?.gone() + } } private fun applyCardShape(card: MaterialCardView) { @@ -149,6 +173,7 @@ class RealContextualNativeInputManager @Inject constructor( onSearchSubmitted: (String) -> Unit, onCameraCaptureRequested: (ValueCallback>) -> Unit, onFilePickerRequested: (ValueCallback>, List) -> Unit, + onNewChatPromptSubmitted: (NativeInputPrompt) -> Unit, ) { widget.configureContextual(tabId) widget.bindChatIdSource(chatIdFlow) @@ -167,8 +192,19 @@ class RealContextualNativeInputManager @Inject constructor( onChatSubmitted = { prompt -> val imagesJson = widget.getImageAttachmentsJson() val filesJson = widget.getFileAttachmentsJson() + val modelId = widget.getSelectedModelId() + val reasoningEffort = widget.getResolvedReasoningEffort() + val selectedTool = widget.getSelectedTool() widget.clearAttachments() - sendPrompt(prompt, widget.getSelectedModelId(), widget.getResolvedReasoningEffort(), widget.getSelectedTool(), imagesJson, filesJson) + if (lastMode == Mode.INPUT) { + // Initial sheet: start a new chat via the ViewModel so the sheet switches to WEBVIEW. + onNewChatPromptSubmitted( + NativeInputPrompt(prompt, modelId, reasoningEffort, selectedTool, imagesJson, filesJson), + ) + } else { + // Chat already in progress: submit into the running chat via the JS event. + sendPrompt(prompt, modelId, reasoningEffort, selectedTool, imagesJson, filesJson) + } widget.clearSelectedTool() widget.text = "" }, diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt index 32a26c81af10..614f94979137 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt @@ -85,7 +85,7 @@ import com.duckduckgo.duckchat.api.DuckChatHistoryNoParams import com.duckduckgo.duckchat.api.viewmodel.DuckChatSharedViewModel import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.R -import com.duckduckgo.duckchat.impl.databinding.FragmentContextualDuckAiBinding +import com.duckduckgo.duckchat.impl.databinding.FragmentContextualDuckAiNativeBinding import com.duckduckgo.duckchat.impl.feature.AIChatDownloadFeature import com.duckduckgo.duckchat.impl.helper.DuckChatJSHelper import com.duckduckgo.duckchat.impl.helper.Mode @@ -209,7 +209,7 @@ class DuckChatContextualFragment : private var pendingFileDownload: FileDownloader.PendingFileDownload? = null private val downloadMessagesJob = ConflatedJob() - private val binding: FragmentContextualDuckAiBinding by viewBinding() + private val binding: FragmentContextualDuckAiNativeBinding by viewBinding() private var pendingUploadTask: ValueCallback>? = null private val root: ViewGroup by lazy { binding.root } @@ -472,6 +472,16 @@ class DuckChatContextualFragment : onFilePickerRequested = { callback, mimeTypes -> launchNativeFilePicker(callback, mimeTypes) }, + onNewChatPromptSubmitted = { submitted -> + viewModel.onPromptSent( + prompt = submitted.prompt, + modelId = submitted.modelId, + reasoningEffort = submitted.reasoningEffort, + selectedTool = submitted.selectedTool, + imagesJson = submitted.imagesJson, + filesJson = submitted.filesJson, + ) + }, ) observeViewModel() @@ -704,8 +714,8 @@ class DuckChatContextualFragment : when (viewState.sheetMode) { DuckChatContextualViewModel.SheetMode.INPUT -> { - binding.contextualModeNativeContent.show() binding.contextualWebviewContainer.gone() + binding.contextualModePrompts.show() contextualNativeInputManager.onInputMode() if (viewState.showChatsIcon) { @@ -715,28 +725,37 @@ class DuckChatContextualFragment : } binding.contextualFire.gone() - renderPageContext(viewState.contextTitle, viewState.contextUrl, viewState.tabId) - - if (viewState.quickActionState == DuckChatContextualViewModel.QuickActionState.ASK_ABOUT_PAGE) { - binding.duckAiContextualLayout.gone() - binding.duckAiAttachContextLayout.gone() - } else if (viewState.showContext) { - binding.duckAiContextualLayout.show() - binding.duckAiAttachContextLayout.gone() + if (viewState.nativeChatInputEnabled) { + // The unified input widget is the composer; ContextualNativeInputManager.onInputMode() + // shows its card. Hide the legacy EditText composer and its context chip/placeholder. + binding.contextualModeNativeContent.gone() } else { - binding.duckAiContextualLayout.gone() - binding.duckAiAttachContextLayout.show() - } - if (viewState.prompt.isNotEmpty()) { - binding.inputField.setText(viewState.prompt) - binding.inputField.setSelection(viewState.prompt.length) - } else { - clearInputField() + binding.contextualModeNativeContent.show() + + renderPageContext(viewState.contextTitle, viewState.contextUrl, viewState.tabId) + + if (viewState.quickActionState == DuckChatContextualViewModel.QuickActionState.ASK_ABOUT_PAGE) { + binding.duckAiContextualLayout.gone() + binding.duckAiAttachContextLayout.gone() + } else if (viewState.showContext) { + binding.duckAiContextualLayout.show() + binding.duckAiAttachContextLayout.gone() + } else { + binding.duckAiContextualLayout.gone() + binding.duckAiAttachContextLayout.show() + } + if (viewState.prompt.isNotEmpty()) { + binding.inputField.setText(viewState.prompt) + binding.inputField.setSelection(viewState.prompt.length) + } else { + clearInputField() + } } } DuckChatContextualViewModel.SheetMode.WEBVIEW -> { binding.contextualModeNativeContent.gone() + binding.contextualModePrompts.gone() binding.contextualWebviewContainer.show() binding.contextualNewChat.show() if (viewState.isFireButtonEnabled) binding.contextualFire.show() else binding.contextualFire.gone() diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModel.kt index 6030987c7765..040623d6d676 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModel.kt @@ -56,6 +56,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import logcat.logcat +import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import javax.inject.Inject @@ -190,6 +191,10 @@ class DuckChatContextualViewModel @Inject constructor( observeCurrentChatDeletion() } } + + duckChat.observeNativeChatInputEnabled() + .onEach { enabled -> _viewState.update { it.copy(nativeChatInputEnabled = enabled) } } + .launchIn(viewModelScope) } // Last chat id we confirmed exists in history. A brand-new chat sets _chatId (from the loaded @@ -253,6 +258,9 @@ class DuckChatContextualViewModel @Inject constructor( // When true, the legacy "+" icon is replaced by the chats icon and shown regardless of sheet mode. val showChatsIcon: Boolean = false, val recentChats: List = emptyList(), + // When true, the initial (INPUT) sheet uses the unified input widget as its composer instead + // of the legacy EditText. Mirrors duckChat.observeNativeChatInputEnabled(). + val nativeChatInputEnabled: Boolean = false, ) fun onSheetReopened() { @@ -386,9 +394,14 @@ class DuckChatContextualViewModel @Inject constructor( fun onPromptSent( prompt: String, followUpPrefill: String? = null, + modelId: String? = null, + reasoningEffort: String? = null, + selectedTool: String? = null, + imagesJson: JSONArray? = null, + filesJson: JSONArray? = null, ) { viewModelScope.launch(dispatchers.io()) { - val contextPrompt = generateContextPrompt(prompt) + val contextPrompt = generateContextPrompt(prompt, modelId, reasoningEffort, selectedTool, imagesJson, filesJson) val prefillText = followUpPrefill?.takeIf { it.isNotEmpty() } val prefillEvent = prefillText?.let { generatePrefillEvent(it) } withContext(dispatchers.main()) { @@ -458,7 +471,14 @@ class DuckChatContextualViewModel @Inject constructor( } } - private fun generateContextPrompt(prompt: String): SubscriptionEventData { + private fun generateContextPrompt( + prompt: String, + modelId: String? = null, + reasoningEffort: String? = null, + selectedTool: String? = null, + imagesJson: JSONArray? = null, + filesJson: JSONArray? = null, + ): SubscriptionEventData { val viewState = _viewState.value val pageContext = if (viewState.showContext) { @@ -479,8 +499,11 @@ class DuckChatContextualViewModel @Inject constructor( duckChatPixels.reportContextualPromptSubmittedWithContextNative() } - val modelId = modelManager.getSelectedModelId() - val reasoningEffort = modelManager.getResolvedReasoningEffort() + // The unified input widget is the source of truth for model/reasoning/tool/attachments when it + // is the composer; fall back to the shared model manager for callers that don't pass them (e.g. + // the Summarize/Ask-about-page quick action). + val resolvedModelId = modelId ?: modelManager.getSelectedModelId() + val resolvedReasoningEffort = reasoningEffort ?: modelManager.getResolvedReasoningEffort() val params = JSONObject().apply { put("platform", "android") @@ -490,11 +513,20 @@ class DuckChatContextualViewModel @Inject constructor( JSONObject().apply { put("prompt", prompt) put("autoSubmit", true) - if (modelId != null) { - put("modelId", modelId) + if (resolvedModelId != null) { + put("modelId", resolvedModelId) + } + if (resolvedReasoningEffort != null) { + put("reasoningEffort", resolvedReasoningEffort) + } + if (selectedTool != null) { + put("toolChoice", JSONArray().apply { put(selectedTool) }) + } + if (imagesJson != null) { + put("images", imagesJson) } - if (reasoningEffort != null) { - put("reasoningEffort", reasoningEffort) + if (filesJson != null) { + put("files", filesJson) } }, ) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt index df2fe27551ff..0ce88b84591f 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -268,6 +269,61 @@ class DuckChatContextualViewModelTest { } } + @Test + fun `when prompt sent with explicit model reasoning tool and attachments then query contains them`() = runTest { + val images = JSONArray().apply { put("image-1") } + val files = JSONArray().apply { put("file-1") } + + testee.subscriptionEventDataFlow.test { + testee.onPromptSent( + prompt = "hello", + modelId = "gpt-5.2", + reasoningEffort = "high", + selectedTool = "web-search", + imagesJson = images, + filesJson = files, + ) + + val event = awaitItem() + val query = event.params.getJSONObject("query") + assertEquals("gpt-5.2", query.getString("modelId")) + assertEquals("high", query.getString("reasoningEffort")) + assertEquals("web-search", query.getJSONArray("toolChoice").getString(0)) + assertEquals("image-1", query.getJSONArray("images").getString(0)) + assertEquals("file-1", query.getJSONArray("files").getString(0)) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when explicit model not provided then falls back to model manager selection`() = runTest { + whenever(modelManager.getSelectedModelId()).thenReturn("fallback-model") + whenever(modelManager.getResolvedReasoningEffort()).thenReturn("low") + + testee.subscriptionEventDataFlow.test { + testee.onPromptSent(prompt = "hello", selectedTool = "web-search") + + val event = awaitItem() + val query = event.params.getJSONObject("query") + assertEquals("fallback-model", query.getString("modelId")) + assertEquals("low", query.getString("reasoningEffort")) + assertEquals("web-search", query.getJSONArray("toolChoice").getString(0)) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when native chat input flag enabled then view state reflects it`() = runTest { + assertFalse(testee.viewState.value.nativeChatInputEnabled) + + (duckChat as FakeDuckChat).setNativeChatInputEnabled(true) + coroutineRule.testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(testee.viewState.value.nativeChatInputEnabled) + } + @Test fun `when page context removed then prompt omits pageContext even if cached`() = runTest { @@ -2323,6 +2379,10 @@ class DuckChatContextualViewModelTest { private val nativeInputFieldSettingEnabled = MutableStateFlow(false) private val nativeChatInputEnabled = MutableStateFlow(false) + fun setNativeChatInputEnabled(enabled: Boolean) { + nativeChatInputEnabled.value = enabled + } + override fun isEnabled(): Boolean = true override fun openDuckChat() = Unit override fun openDuckChatWithAutoPrompt(query: String) = Unit diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealContextualNativeInputManagerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealContextualNativeInputManagerTest.kt index cc5450009ca3..e552dba6159c 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealContextualNativeInputManagerTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealContextualNativeInputManagerTest.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -156,6 +157,110 @@ class RealContextualNativeInputManagerTest { verify(card).visibility = View.GONE } + @Test + fun `when native chat input enabled and onInputMode then card shown and picker enabled`() { + val enabled = MutableStateFlow(true) + whenever(duckChat.observeNativeChatInputEnabled()).thenReturn(enabled) + val card = mockCard() + // View.show() only writes visibility when it isn't already VISIBLE; a mock defaults to 0 + // (VISIBLE), so start it GONE to observe the transition. + whenever(card.visibility).thenReturn(View.GONE) + val widget = mock() + testee.init( + tabId = "tab", + card = card, + widget = widget, + jsMessaging = mock(), + lifecycleOwner = lifecycleOwner(), + chatIdFlow = emptyFlow(), + onSearchSubmitted = {}, + ) + + testee.onInputMode() + + verify(card).visibility = View.VISIBLE + verify(widget).setModelPickerEnabled(true) + } + + @Test + fun `when native chat input disabled and onInputMode then card hidden`() { + val enabled = MutableStateFlow(false) + whenever(duckChat.observeNativeChatInputEnabled()).thenReturn(enabled) + val card = mockCard() + val widget = mock() + testee.init( + tabId = "tab", + card = card, + widget = widget, + jsMessaging = mock(), + lifecycleOwner = lifecycleOwner(), + chatIdFlow = emptyFlow(), + onSearchSubmitted = {}, + ) + + testee.onInputMode() + + verify(card).visibility = View.GONE + } + + @Test + fun `when input mode and chat submitted then routes to new chat callback with widget selections`() { + val enabled = MutableStateFlow(true) + whenever(duckChat.observeNativeChatInputEnabled()).thenReturn(enabled) + val widget = mock() + whenever(widget.getSelectedModelId()).thenReturn("model-1") + whenever(widget.getResolvedReasoningEffort()).thenReturn("high") + whenever(widget.getSelectedTool()).thenReturn("web-search") + var submitted: NativeInputPrompt? = null + testee.init( + tabId = "tab", + card = mockCard(), + widget = widget, + jsMessaging = mock(), + lifecycleOwner = lifecycleOwner(), + chatIdFlow = emptyFlow(), + onSearchSubmitted = {}, + onNewChatPromptSubmitted = { submitted = it }, + ) + testee.onInputMode() + + val captor = argumentCaptor<(String) -> Unit>() + verify(widget).bindInputEvents(any(), any(), captor.capture()) + captor.firstValue.invoke("hello") + + assertEquals("hello", submitted?.prompt) + assertEquals("model-1", submitted?.modelId) + assertEquals("high", submitted?.reasoningEffort) + assertEquals("web-search", submitted?.selectedTool) + } + + @Test + fun `when web view mode and chat submitted then sends in-chat prompt event and skips new chat callback`() { + val enabled = MutableStateFlow(true) + whenever(duckChat.observeNativeChatInputEnabled()).thenReturn(enabled) + val widget = mock() + val jsMessaging = mock() + var submitted: NativeInputPrompt? = null + testee.init( + tabId = "tab", + card = mockCard(), + widget = widget, + jsMessaging = jsMessaging, + lifecycleOwner = lifecycleOwner(), + chatIdFlow = emptyFlow(), + onSearchSubmitted = {}, + onNewChatPromptSubmitted = { submitted = it }, + ) + testee.onWebViewMode() + + val captor = argumentCaptor<(String) -> Unit>() + verify(widget).bindInputEvents(any(), any(), captor.capture()) + captor.firstValue.invoke("hello") + + assertNull(submitted) + verify(jsMessaging).sendSubscriptionEvent(any()) + } + private fun mockCard(): MaterialCardView { val card = mock() val resources = mock() From 7677a924b43e4f32b4e7a5964f0b1427ad8083fe Mon Sep 17 00:00:00 2001 From: David Gonzalez Date: Thu, 2 Jul 2026 16:48:23 +0200 Subject: [PATCH 3/5] Style contextual UTI card to match Duck.ai Make the unified input card in the contextual sheet look like the Duck.ai omnibar input card: it now floats with an all-around margin and fully rounded corners, sits on a daxColorDuckAiBackground surround, and uses a daxColorWindow fill so the rounded corners read against the background. Keep the bottom tools row (attachments, options, model, reasoning) always visible in the contextual sheet rather than only revealing it on focus, extracting the decision into a testable shouldShowBottomRow helper. Covered by NativeInputModeWidgetBottomRowTest. Note: on the initial (INPUT) sheet the tools row still measures zero height even though its visibility flags are correct; it renders fine in webview mode. That layout issue is tracked separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ContextualNativeInputManager.kt | 7 +- .../views/NativeInputModeWidget.kt | 34 ++++- .../fragment_contextual_duck_ai_native.xml | 7 +- .../ui/NativeInputModeWidgetBottomRowTest.kt | 123 ++++++++++++++++++ 4 files changed, 159 insertions(+), 12 deletions(-) create mode 100644 duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetBottomRowTest.kt diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt index 64add6fc26fd..9021d01c67ad 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt @@ -155,14 +155,13 @@ class RealContextualNativeInputManager @Inject constructor( } private fun applyCardShape(card: MaterialCardView) { + // The card floats within the input area (margins on all sides), matching the Duck.ai omnibar + // card, so all four corners are rounded rather than the docked top-only shape. val radius = card.resources.getDimension( com.duckduckgo.mobile.android.R.dimen.extraLargeShapeCornerRadius, ) card.shapeAppearanceModel = card.shapeAppearanceModel.toBuilder() - .setTopLeftCornerSize(radius) - .setTopRightCornerSize(radius) - .setBottomLeftCornerSize(0f) - .setBottomRightCornerSize(0f) + .setAllCornerSizes(radius) .build() } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index 00d8bf5fd98e..c740333b9a63 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt @@ -590,11 +590,14 @@ class NativeInputModeWidget @JvmOverloads constructor( private fun updateBottomRowVisibility() { val bottomRow = findViewById(R.id.inputModeWidgetBottomRow) ?: return - val suppress = nativeInputState?.shouldSuppressBottomRow() == true - val visible = isChatTabSelected() && - (inputField.hasFocus() || previewEnterFocus) && - !isStreaming && - !suppress + val visible = shouldShowBottomRow( + onChatTab = isChatTabSelected(), + isContextual = isContextualWidget, + hasFocus = inputField.hasFocus(), + previewEnterFocus = previewEnterFocus, + isStreaming = isStreaming, + suppress = nativeInputState?.shouldSuppressBottomRow() == true, + ) bottomRow.visibility = if (visible) VISIBLE else GONE } @@ -1190,7 +1193,7 @@ class NativeInputModeWidget @JvmOverloads constructor( } private fun applyOmnibarShape() { - // The contextual sheet's parent card has a top-only rounded shape applied by + // The contextual sheet's parent card has its rounded shape applied by // ContextualNativeInputManager.applyCardShape(); never overwrite it from here. The // shared per-tab state store can briefly emit a BROWSER state with toggleVisible=false // (e.g. SEARCH_ONLY users when the main widget publishes first), which would otherwise @@ -1637,6 +1640,25 @@ internal fun NativeInputState.shouldSuppressBottomRow(): Boolean = inputMode == NativeInputState.InputMode.SEARCH_ONLY && inputContext == NativeInputState.InputContext.BROWSER +/** + * Whether the bottom tools row (attachments, options, reasoning, model picker) should be visible. + * On the Duck.ai chat tab, while not streaming and not suppressed. In the contextual sheet the widget + * is the dedicated composer, so the row is always visible there; everywhere else it only reveals once + * the input has focus (or the pre-focus preview state is active), matching the omnibar behaviour. + */ +internal fun shouldShowBottomRow( + onChatTab: Boolean, + isContextual: Boolean, + hasFocus: Boolean, + previewEnterFocus: Boolean, + isStreaming: Boolean, + suppress: Boolean, +): Boolean = + onChatTab && + (isContextual || hasFocus || previewEnterFocus) && + !isStreaming && + !suppress + /** * Bottom-row controls (model / reasoning / options / attachment) are shown only on the Duck.ai * chat tab and only when no chat is streaming. While streaming, the bottom row stays visible for diff --git a/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai_native.xml b/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai_native.xml index 70e796be591c..7f71f24b24ef 100644 --- a/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai_native.xml +++ b/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai_native.xml @@ -159,7 +159,8 @@ + android:layout_height="wrap_content" + android:background="?attr/daxColorDuckAiBackground"> @@ -182,6 +184,7 @@