Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something we should extract in order to share between both managers (and then refactor)?


interface ContextualNativeInputManager {
fun init(
tabId: String,
Expand All @@ -51,6 +65,9 @@ interface ContextualNativeInputManager {
onSearchSubmitted: (String) -> Unit,
onCameraCaptureRequested: (ValueCallback<Array<Uri>>) -> Unit = {},
onFilePickerRequested: (ValueCallback<Array<Uri>>, List<String>) -> 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 = {},

@joshliebe joshliebe Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would onNewPromptSubmitted be a better name?

)

fun onWebViewMode()
Expand Down Expand Up @@ -90,13 +107,14 @@ class RealContextualNativeInputManager @Inject constructor(
onSearchSubmitted: (String) -> Unit,
onCameraCaptureRequested: (ValueCallback<Array<Uri>>) -> Unit,
onFilePickerRequested: (ValueCallback<Array<Uri>>, List<String>) -> 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)
}

Expand Down Expand Up @@ -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()
}

Expand All @@ -149,6 +172,7 @@ class RealContextualNativeInputManager @Inject constructor(
onSearchSubmitted: (String) -> Unit,
onCameraCaptureRequested: (ValueCallback<Array<Uri>>) -> Unit,
onFilePickerRequested: (ValueCallback<Array<Uri>>, List<String>) -> Unit,
onNewChatPromptSubmitted: (NativeInputPrompt) -> Unit,
) {
widget.configureContextual(tabId)
widget.bindChatIdSource(chatIdFlow)
Expand All @@ -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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unset mode uses in-chat submit

Medium Severity

New chat routing requires lastMode == Mode.INPUT, but lastMode stays null until onInputMode or onWebViewMode runs. A submit before the first renderViewState takes the in-chat sendPrompt JS path instead of onNewChatPromptSubmitted, so the initial-sheet flow can miss starting a new chat.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 80b3243. Configure here.

widget.clearSelectedTool()
widget.text = ""
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keyboard resize ignores UTI focus

Medium Severity

The IME visibility listener only calls onKeyboardVisibilityChanged when the hidden legacy inputField has focus. Focusing the contextual NativeInputModeWidget opens the keyboard without expanding or half-expanding the sheet as before.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 172be5e. Configure here.

private var pendingUploadTask: ValueCallback<Array<Uri>>? = null

private val root: ViewGroup by lazy { binding.root }
Expand Down Expand Up @@ -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,
)
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick action ignores UTI text

Medium Severity

With nativeChatInputEnabled, the quick-action handler still passes binding.inputField text into onQuickActionClicked, but the legacy EditText is hidden and the user types in contextualNativeInputWidget. Follow-up prefill and legacy summarize merging therefore see an empty string even when the UTI has content.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 172be5e. Configure here.

)
observeViewModel()

Expand Down Expand Up @@ -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) {
Expand All @@ -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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manual page attach UI hidden

Medium Severity

With native chat input enabled, the entire legacy composer block—including duckAiAttachContextLayout and the page-context chip—is gone. Users who rely on manual “attach page content” (automatic attachment off and without the improved ask-about-page quick action) have no UI to attach context before sending from the UTI.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 172be5e. Configure here.

} 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think a switch would be more readable here

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()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prompt state not synced to widget

Medium Severity

When nativeChatInputEnabled is true, renderViewState skips applying viewState.prompt to any visible composer. Updates such as replacePrompt from the summarize quick action only change ViewModel state, so the UTI stays empty and the user never sees the prefilled prompt.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 172be5e. Configure here.

}
}

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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ChatHistoryItem> = 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() {
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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")
Expand All @@ -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)
}
},
)
Expand Down
Loading
Loading