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..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 @@ -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,20 +143,25 @@ 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) { + // 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() } @@ -149,6 +172,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 +191,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 35d00f0c98e1..f2f41029ab1c 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 @@ -64,6 +64,7 @@ import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.ui.menu.PopupMenu import com.duckduckgo.common.ui.view.PopupMenuItemView import com.duckduckgo.common.ui.view.dialog.ActionBottomSheetDialog +import com.duckduckgo.common.ui.view.getColorFromAttr import com.duckduckgo.common.ui.view.gone import com.duckduckgo.common.ui.view.makeSnackbarWithNoBottomInset import com.duckduckgo.common.ui.view.show @@ -85,7 +86,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 @@ -120,7 +121,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 @@ -209,7 +210,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 +473,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 +715,11 @@ class DuckChatContextualFragment : when (viewState.sheetMode) { DuckChatContextualViewModel.SheetMode.INPUT -> { - binding.contextualModeNativeContent.show() binding.contextualWebviewContainer.gone() + binding.contextualModePrompts.show() + binding.contextualInputContainer.setBackgroundColor( + requireContext().getColorFromAttr(com.duckduckgo.mobile.android.R.attr.daxColorSurface), + ) contextualNativeInputManager.onInputMode() if (viewState.showChatsIcon) { @@ -715,28 +729,40 @@ 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.contextualInputContainer.setBackgroundColor( + requireContext().getColorFromAttr(com.duckduckgo.mobile.android.R.attr.daxColorDuckAiBackground), + ) 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/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..f83fb6c34c4c 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 @@ -375,6 +375,10 @@ class NativeInputModeWidget @JvmOverloads constructor( launch { viewModel.plugins.collect { plugins -> for (plugin in plugins) { + // The start-chat shortcut is a search-only address-bar affordance; it has no place + // in the contextual sheet's Duck.ai composer (and reads the shared per-tab state, + // which can be search-only), so skip it there. + if (isContextualWidget && plugin.containerId == R.id.startChatContainer) continue val container = findViewById(plugin.containerId) ?: continue val pluginView = plugin.createView(context, this@NativeInputModeWidget) container.removeAllViews() @@ -573,7 +577,10 @@ class NativeInputModeWidget @JvmOverloads constructor( // setToggleVisible — e.g. re-show the toggle after the keyboard has been dismissed. // Only animate on focus-gain — focus-loss pairs with an instant setToggleVisible hide, // and animating the padding shrink afterwards looks like a two-step collapse. - if (hasFocus) beginFocusTransition() + if (hasFocus) { + beginFocusTransition() + reassertContextualPluginVisibility() + } updateBottomRowVisibility() applyVerticalPaddingForFocus() nativeInputState?.let { updateFireButtonVisibility(it) } @@ -598,6 +605,21 @@ class NativeInputModeWidget @JvmOverloads constructor( bottomRow.visibility = if (visible) VISIBLE else GONE } + /** + * In the contextual sheet the widget is reused across the sheet being hidden and shown again; + * on reopen the bottom-row plugin containers can end up hidden (their visibility is set once when + * plugins first load and isn't restored on reuse). Re-assert them here, from the focus path so the + * change rides the focus layout transition and re-measures. Scoped to the contextual widget so the + * omnibar is untouched. The model picker is gated on its enabled state so it stays hidden mid-chat. + */ + private fun reassertContextualPluginVisibility() { + if (!isContextualWidget) return + val onChatTab = isChatTabSelected() + findViewById(R.id.attachButtonContainer)?.isVisible = onChatTab + findViewById(R.id.optionsButtonContainer)?.isVisible = onChatTab + findViewById(R.id.modelPickerContainer)?.isVisible = onChatTab && viewModel.modelPickerEnabled.value + } + private fun updateToggleVisibilityForState() { if (isDuckAiPageContext()) return applyToggleVisibility(nativeInputState?.toggleVisible ?: false) @@ -679,7 +701,19 @@ class NativeInputModeWidget @JvmOverloads constructor( floatingButtons?.setNewLineButtonVisible(visible) } - private fun applyState(state: NativeInputState) { + private fun applyState(incomingState: NativeInputState) { + // The contextual sheet is always a Duck.ai chat surface, but the shared per-tab state store can + // carry a browser/search state written by the main omnibar widget (e.g. for search-only users). + // Force the Duck.ai context/toggle here so the contextual widget never renders that search state, + // which would otherwise flip the tab off "chat" (hiding the chat-tab controls). + val state = if (isContextualWidget) { + incomingState.copy( + inputContext = NativeInputState.InputContext.DUCK_AI_CONTEXTUAL, + toggleSelection = NativeInputState.ToggleSelection.DUCK_AI, + ) + } else { + incomingState + } val previousState = nativeInputState val firstStateEmission = previousState == null val contextChanged = previousState?.inputContext != state.inputContext @@ -1190,7 +1224,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 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..34ce2acde0bb --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai_native.xml @@ -0,0 +1,394 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..62b86135f791 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,111 @@ class RealContextualNativeInputManagerTest { verify(card).visibility = View.GONE } + @Test + fun `when native chat input enabled and onInputMode then card shown`() { + 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() + + // The model picker is re-enabled via the bound modelPickerEnabled flow (covered separately); + // here we assert the contextual card itself is shown in INPUT mode. + verify(card).visibility = View.VISIBLE + } + + @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()