Skip to content
Merged
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 @@ -493,16 +493,18 @@ class RealNativeInputManager @Inject constructor(
if (!isBottom) {
setFloatingSubmitContainer(createFloatingSubmitContainer())
}
// Per-tab chatId (null on new chats) published into NativeInputState for
// consumers (reasoning picker, submission) to resolve per-chat state.
val chatIdFlow = currentTabUrl.map { extractDuckAiChatId(it) }
// Picker tied to whether the current tab is a Duck.ai page that already has a chatId (existing chat) or new chat.
bindModelPickerEnabledSource(chatIdFlow.map { it == null })
bindChatIdSource(chatIdFlow)
}
bindSearchCallbacks(widgetView, callbacks)
bindAutocompleteVisibility(widgetView)
bindChatSuggestions(widgetView, lifecycleOwner, callbacks)
bindSearchTabAutocompleteClearing(widgetView, callbacks.onClearAutocomplete)
bindVoiceButtons(widgetView, callbacks)
// Picker tied to whether the current tab is a Duck.ai page that already has a chatId (existing chat) or new chat.
widgetFrom(widgetView)?.bindModelPickerEnabledSource(
currentTabUrl.map { !isExistingDuckAiChat(it) },
)
}

private fun bindVoiceButtons(
Expand Down Expand Up @@ -745,11 +747,14 @@ class RealNativeInputManager @Inject constructor(
}

/** True if [rawUrl] points at an in-progress Duck.ai chat (Duck.ai URL with a non-blank `chatID`). */
internal fun isExistingDuckAiChat(rawUrl: String?): Boolean {
if (rawUrl.isNullOrBlank()) return false
val uri = runCatching { rawUrl.toUri() }.getOrNull() ?: return false
if (!duckChat.isDuckChatUrl(uri)) return false
return !uri.getQueryParameter("chatID").isNullOrBlank()
internal fun isExistingDuckAiChat(rawUrl: String?): Boolean = extractDuckAiChatId(rawUrl) != null

/** Returns the `chatID` query param if [rawUrl] is a Duck.ai chat URL, else `null`. */
internal fun extractDuckAiChatId(rawUrl: String?): String? {
if (rawUrl.isNullOrBlank()) return null
val uri = runCatching { rawUrl.toUri() }.getOrNull() ?: return null
if (!duckChat.isDuckChatUrl(uri)) return null
return uri.getQueryParameter("chatID")?.takeIf { it.isNotBlank() }
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.voice.api.VoiceSearchAvailability
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
Expand Down Expand Up @@ -105,4 +107,39 @@ class RealNativeInputManagerTest {

verify(duckChat).isDuckChatUrl(Uri.parse(raw))
}

@Test
fun whenUrlIsNullThenExtractDuckAiChatIdNull() {
assertNull(testee.extractDuckAiChatId(null))
}

@Test
fun whenUrlIsBlankThenExtractDuckAiChatIdNull() {
assertNull(testee.extractDuckAiChatId(""))
assertNull(testee.extractDuckAiChatId(" "))
}

@Test
fun whenUrlIsNotDuckAiThenExtractDuckAiChatIdNull() {
whenever(duckChat.isDuckChatUrl(any())).thenReturn(false)
assertNull(testee.extractDuckAiChatId("https://example.com/?chatID=abcd"))
}

@Test
fun whenDuckAiUrlWithoutChatIdThenExtractDuckAiChatIdNull() {
whenever(duckChat.isDuckChatUrl(any())).thenReturn(true)
assertNull(testee.extractDuckAiChatId("https://duck.ai/"))
}

@Test
fun whenDuckAiUrlWithBlankChatIdThenExtractDuckAiChatIdNull() {
whenever(duckChat.isDuckChatUrl(any())).thenReturn(true)
assertNull(testee.extractDuckAiChatId("https://duck.ai/?chatID="))
}

@Test
fun whenDuckAiUrlWithChatIdThenExtractDuckAiChatIdReturnsValue() {
whenever(duckChat.isDuckChatUrl(any())).thenReturn(true)
assertEquals("abc-123", testee.extractDuckAiChatId("https://duck.ai/?chatID=abc-123"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ data class NativeInputState(
val inputPosition: InputPosition = InputPosition.TOP,
val toggleSelection: ToggleSelection = defaultToggleFor(inputContext),
val selectedTool: String? = null,
/** Set when the active tab is a Duck.ai page already attached to an existing chat. */
val chatId: String? = null,
) {
enum class InputMode {
SEARCH_AND_DUCK_AI,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import logcat.logcat
import javax.inject.Inject

data class ChatTabSuggestions(
Expand Down Expand Up @@ -121,6 +122,8 @@ class NativeInputModeWidgetViewModel @Inject constructor(
private val _modelPickerEnabled = MutableStateFlow(true)
val modelPickerEnabled: StateFlow<Boolean> = _modelPickerEnabled.asStateFlow()

private val _chatId = MutableStateFlow<String?>(null)

private val commandChannel = Channel<Command>(capacity = 1, onBufferOverflow = DROP_OLDEST)
val commands = commandChannel.receiveAsFlow()

Expand Down Expand Up @@ -207,6 +210,18 @@ class NativeInputModeWidgetViewModel @Inject constructor(
}
}
}

// Publish chatId to per-tab state. setActiveChatId can fire during widget attach before
// configure() sets activeTabId — _chatId holds the value until then.
viewModelScope.launch {
combine(_chatId, activeTabId.filterNotNull()) { chatId, tabId -> tabId to chatId }
Comment thread
YoussefKeyrouz marked this conversation as resolved.
.collect { (tabId, chatId) ->
nativeInputStatePublisher.update(tabId) { it.copy(chatId = chatId) }
// TODO: logs the chatId observers will see for this tab. Added for testing.
// Remove once consumer logic is implemented.
logcat { "Duck.ai native input: active chatId for tab=$tabId is now $chatId" }
}
}
}

val chatState: Flow<ChatState> = duckChatInternal.chatState
Expand Down Expand Up @@ -247,6 +262,10 @@ class NativeInputModeWidgetViewModel @Inject constructor(
nativeInputStatePublisher.update(tabId) { it.copy(selectedTool = tool) }
}

fun setActiveChatId(chatId: String?) {
_chatId.value = chatId
}

fun configure(tabId: String, isDuckAiMode: Boolean, isBottom: Boolean) {
activeTabId.value = tabId
val context = if (isDuckAiMode) NativeInputState.InputContext.DUCK_AI else NativeInputState.InputContext.BROWSER
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ interface NativeInputWidget {
fun setWidgetPosition(isBottom: Boolean)
fun setWidgetRootView(view: View)

/**
* Binds a reactive source of the active chat id for this tab.
* The widget forwards changes into the [NativeInputState] so observers can react.
*/
fun bindChatIdSource(source: Flow<String?>)

fun bindAttachmentCallbacks(
onCameraCaptureRequested: (ValueCallback<Array<Uri>>) -> Unit,
onFilePickerRequested: (ValueCallback<Array<Uri>>, List<String>) -> Unit,
Expand Down Expand Up @@ -203,6 +209,8 @@ class NativeInputModeWidget @JvmOverloads constructor(
private var pluginsJob: Job? = null
private var modelPickerEnabledJob: Job? = null
private var modelPickerEnabledSource: Flow<Boolean>? = null
private var chatIdJob: Job? = null
private var chatIdSource: Flow<String?>? = null
private var modelPickerView: ModelPicker? = null
private var optionsView: OptionsView? = null
private var chatSuggestionsUserEnabled: Boolean = true
Expand Down Expand Up @@ -260,6 +268,7 @@ class NativeInputModeWidget @JvmOverloads constructor(
super.onAttachedToWindow()
setupPlugins()
observeModelPickerEnabledSource()
observeChatIdSource()
applyNativeStyling()
observeChatState()
observeChatSuggestionsEnabled()
Expand Down Expand Up @@ -358,6 +367,8 @@ class NativeInputModeWidget @JvmOverloads constructor(
pluginsJob = null
modelPickerEnabledJob?.cancel()
modelPickerEnabledJob = null
chatIdJob?.cancel()
chatIdJob = null
modelPickerView = null
optionsView = null
widgetRoot = null
Expand Down Expand Up @@ -813,6 +824,21 @@ class NativeInputModeWidget @JvmOverloads constructor(
.launchIn(scope)
}

override fun bindChatIdSource(source: Flow<String?>) {
chatIdSource = source
if (isAttachedToWindow) observeChatIdSource()
}

private fun observeChatIdSource() {
val source = chatIdSource ?: return
val scope = findViewTreeLifecycleOwner()?.lifecycleScope ?: return
chatIdJob?.cancel()
chatIdJob = source
.distinctUntilChanged()
.onEach { viewModel.setActiveChatId(it) }
.launchIn(scope)
}

override fun getImageAttachmentsJson(): JSONArray? = attachmentView?.getImageAttachmentsJson()

override fun getFileAttachmentsJson(): JSONArray? = attachmentView?.getFileAttachmentsJson()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,49 @@ class NativeInputModeWidgetViewModelTest {

// endregion

// region setActiveChatId

@Test
fun whenSetActiveChatIdThenPublisherUpdatesChatIdForActiveTab() = runTest {
val tabId = "tab-A"
testee.configure(tabId = tabId, isDuckAiMode = false, isBottom = false)
testee.setActiveChatId("chat-123")
advanceUntilIdle()

assertEquals("chat-123", nativeInputStateProvider.stateForTab(tabId).value.chatId)
}

@Test
fun whenSetActiveChatIdWithNullThenPublisherClearsChatId() = runTest {
val tabId = "tab-A"
testee.configure(tabId = tabId, isDuckAiMode = false, isBottom = false)
testee.setActiveChatId("chat-123")
advanceUntilIdle()

testee.setActiveChatId(null)
advanceUntilIdle()

assertNull(nativeInputStateProvider.stateForTab(tabId).value.chatId)
}

@Test
fun whenSetActiveChatIdBeforeConfigureThenChatIdIsBufferedAndPublishedOnConfigure() = runTest {
// Regression for the attach race: bindChatIdSource can fire setActiveChatId synchronously
// during widget attach, before configure() sets activeTabId.value. The buffered value must
// be replayed once a tabId becomes available.
val freshViewModel = createViewModel()

freshViewModel.setActiveChatId("chat-123")
advanceUntilIdle()

freshViewModel.configure(tabId = "tab-A", isDuckAiMode = false, isBottom = false)
advanceUntilIdle()

assertEquals("chat-123", nativeInputStateProvider.stateForTab("tab-A").value.chatId)
}

// endregion

// region fireChatHistorySelectedPixel

@Test
Expand Down
Loading