Skip to content

Commit 0f9c816

Browse files
malmsteinDavid Gonzalezclaude
authored
Migrate StartChat plugin to NativeInputStateProvider (#8570)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1174433894299346/task/1214802413219916?focus=true Stacked on #8556 — base is `feature/david/native_input_tabid_non_null` until that merges. ### Description First plugin migration onto the per-tab push model. `StartChatViewModel` drops `DuckChatInputModeState` and observes the tab's `NativeInputState` via `NativeInputStateProvider`. - `NativeInputState` gets a stored `toggleSelection: ToggleSelection` field (defaults to today's context-derived value via `NativeInputState.defaultToggleFor`). `toggleSelection` is distinct from `inputContext`: the context says where the widget lives, the selection says which tab the user tapped — they can diverge inside a browser omnibar. - `NativeInputHost` exposes `val tabId: String`. The widget implements it from `activeTabId`; reads pre-configure throw, mirroring `getInputState()`. - Widget's `configureMainButtonsVisibility` calls `viewModel.setToggleSelection(...)` on every `onTabSelected` / `onTabReselected`, before the existing `updatePluginContainerVisibility` call. `WidgetConfig` carries a nullable `toggleSelection` override; `configure()` resets it (null = follow context default). - `StartChatViewModel` injects `NativeInputStateProvider` and exposes `bindTabId(tabId)`; the combined `isVisible` flow `flatMapLatest`s on `stateForTab(tabId)` and gates on `toggleSelection == SEARCH`. - `StartChatView` stashes the host's tabId before attach and forwards it to the VM from `onAttachedToWindow`; the plugin's `createView` calls `bindTabId(host.tabId)` alongside the existing `onIconClicked` wiring. `DuckChatInputModeState` stays in place — still consumed by ambient UI (NTP background logo). Removal happens once nothing reads it. `updatePluginContainerVisibility` carries a TODO: it becomes redundant once every plugin reads `toggleSelection` from `NativeInputStateProvider` and decides its own visibility. ### Steps to test this PR _StartChat icon visibility_ - [ ] Enable Duck.ai feature + user setting on; disable input-screen setting (search omnibar mode). On a website, focus the omnibar — StartChat icon visible. - [ ] Same setup, tap the Duck.ai tab in the toggle — icon hides. - [ ] Tap back to the Search tab — icon reappears. - [ ] Disable Duck.ai user setting — icon hides regardless of tab. - [ ] Enable input-screen setting (SEARCH_AND_DUCK_AI mode) — icon hides. _Toggle / tab switching across tabs_ - [ ] Open a website on tab A, tap the Duck.ai toggle. Switch to a fresh tab B in the browser. The widget should reflect tab B's selection independently from tab A. ### UI changes No visual change to existing UI; refactor of how the StartChat icon's visibility is wired. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate risk: refactors how native-input state is derived and propagated across tabs, which can affect toggle selection, plugin visibility, and SEARCH_ONLY vs SEARCH_AND_DUCK_AI UI rendering during tab switches. > > **Overview** > **Native input state is reworked to be active-tab driven.** `NativeInputState` drops `tabId`, adds a stored `toggleSelection` (defaulted via `defaultToggleFor`), and `NativeInputStateProvider` now exposes a `state: Flow<NativeInputState>` that follows the selected browser tab. > > **State propagation and UI syncing are updated.** `RealNativeInputStateStore` now derives `state` from `TabRepository.flowSelectedTab` (with a lazy injection to avoid a Dagger cycle), while `NativeInputModeWidgetViewModel` publishes widget-owned fields via `publisher.update` and tracks per-tab `toggleSelection`, resetting it on `configure()`. `NativeInputModeWidget` always observes `viewModel.state` (avoiding placeholder `zero()` emissions), keeps `TabLayout` selection synchronized to `toggleSelection`, and only writes selection back to state when the toggle row is user-visible. > > **Plugins are migrated off host state reads.** `NativeInputHost.getInputState()` is removed; `ModelPickerView` and `StartChatViewModel` now observe `NativeInputStateProvider.state` to drive surface/visibility logic, with new tests covering active-tab emissions and StartChat visibility gating. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 50d8c0d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: David Gonzalez <malmstein@Davids-MacBook-Pro.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5f8b947 commit 0f9c816

12 files changed

Lines changed: 397 additions & 59 deletions

File tree

duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/nativeinput/NativeInputState.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ data class NativeInputState(
2020
val inputMode: InputMode,
2121
val inputContext: InputContext,
2222
val inputPosition: InputPosition = InputPosition.TOP,
23-
val tabId: String,
23+
val toggleSelection: ToggleSelection = defaultToggleFor(inputContext),
2424
) {
2525
enum class InputMode {
2626
SEARCH_AND_DUCK_AI,
@@ -37,23 +37,21 @@ data class NativeInputState(
3737

3838
val isBottom: Boolean get() = inputPosition == InputPosition.BOTTOM
3939

40-
val defaultToggleSelection: ToggleSelection
41-
get() = when (inputContext) {
40+
companion object {
41+
fun defaultToggleFor(context: InputContext): ToggleSelection = when (context) {
4242
InputContext.DUCK_AI, InputContext.DUCK_AI_CONTEXTUAL -> ToggleSelection.DUCK_AI
4343
InputContext.BROWSER -> ToggleSelection.SEARCH
4444
}
4545

46-
companion object {
4746
/**
4847
* Placeholder state used by [NativeInputStateProvider] for a tab that has not yet been
4948
* published to. Observers should not rely on these values: the native input widget overwrites
5049
* them via [NativeInputStatePublisher.publish] as soon as it is configured for the tab.
5150
*/
52-
fun zero(tabId: String): NativeInputState = NativeInputState(
51+
fun zero(): NativeInputState = NativeInputState(
5352
inputMode = InputMode.SEARCH_AND_DUCK_AI,
5453
inputContext = InputContext.BROWSER,
5554
inputPosition = InputPosition.TOP,
56-
tabId = tabId,
5755
)
5856
}
5957
}

duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/nativeinput/NativeInputStateProvider.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,22 @@
1616

1717
package com.duckduckgo.duckchat.api.nativeinput
1818

19+
import kotlinx.coroutines.flow.Flow
1920
import kotlinx.coroutines.flow.StateFlow
2021

2122
/**
2223
* Read-only access to the per-tab native input state. Components that need to react to input state
23-
* changes (toggle visibility, model selection, etc.) observe the flow for their tab.
24+
* changes (toggle visibility, model selection, etc.) observe [state], which follows the currently
25+
* selected browser tab and re-emits both when the tab changes and when the active tab's state is
26+
* republished.
27+
*
28+
* [stateForTab] is for callers that already know a specific tabId and need synchronous keyed access
29+
* — primarily the native input widget itself; plugins should observe [state].
2430
*
2531
* The writer-side counterpart is [NativeInputStatePublisher], which is reserved for the native input
2632
* widget; plugins must not depend on it.
2733
*/
2834
interface NativeInputStateProvider {
35+
val state: Flow<NativeInputState>
2936
fun stateForTab(tabId: String): StateFlow<NativeInputState>
3037
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/nativeinput/NativeInputPlugin.kt

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import android.view.View
2121
import com.duckduckgo.anvil.annotations.ContributesActivePluginPoint
2222
import com.duckduckgo.common.utils.plugins.ActivePlugin
2323
import com.duckduckgo.di.scopes.AppScope
24-
import com.duckduckgo.duckchat.api.nativeinput.NativeInputState
2524

2625
sealed class PromptContribution {
2726
data class ModelSelection(val modelId: String) : PromptContribution()
@@ -31,8 +30,8 @@ sealed class PromptContribution {
3130

3231
/**
3332
* Communication surface from a plugin back to the host widget. Plugins use it to act on the host
34-
* (e.g. [submit]) and to read the host's current [NativeInputState] when their behaviour depends on it,
35-
* without coupling to the widget class directly.
33+
* (e.g. [submit]) without coupling to the widget class directly. State that depends on the active
34+
* tab is observed via `NativeInputStateProvider.state` rather than reached for through the host.
3635
*/
3736
interface NativeInputHost {
3837
/** Submit the current input as a chat message; opens a new chat session if the input is empty. */
@@ -43,9 +42,6 @@ interface NativeInputHost {
4342
fun showReasoningPicker(showing: Boolean)
4443

4544
fun attachmentChanged(hasAttachments: Boolean, limitExceeded: Boolean, supportsUpload: Boolean)
46-
47-
/** Current input state of the host widget (mode, context, position). */
48-
fun getInputState(): NativeInputState
4945
}
5046

5147
interface NativeInputPlugin : ActivePlugin {

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/nativeinput/RealNativeInputStateStore.kt

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,50 @@
1616

1717
package com.duckduckgo.duckchat.impl.nativeinput
1818

19+
import com.duckduckgo.app.tabs.model.TabRepository
1920
import com.duckduckgo.di.scopes.AppScope
2021
import com.duckduckgo.duckchat.api.nativeinput.NativeInputState
2122
import com.duckduckgo.duckchat.api.nativeinput.NativeInputStateProvider
2223
import com.duckduckgo.duckchat.api.nativeinput.NativeInputStatePublisher
2324
import com.squareup.anvil.annotations.ContributesBinding
25+
import dagger.Lazy
2426
import dagger.SingleInstanceIn
27+
import kotlinx.coroutines.ExperimentalCoroutinesApi
28+
import kotlinx.coroutines.flow.Flow
2529
import kotlinx.coroutines.flow.MutableStateFlow
2630
import kotlinx.coroutines.flow.StateFlow
31+
import kotlinx.coroutines.flow.distinctUntilChanged
32+
import kotlinx.coroutines.flow.filterNotNull
33+
import kotlinx.coroutines.flow.flatMapLatest
34+
import kotlinx.coroutines.flow.map
2735
import kotlinx.coroutines.flow.update
2836
import java.util.concurrent.ConcurrentHashMap
2937
import javax.inject.Inject
3038

39+
@OptIn(ExperimentalCoroutinesApi::class)
3140
@SingleInstanceIn(AppScope::class)
3241
@ContributesBinding(AppScope::class, boundType = NativeInputStateProvider::class)
3342
@ContributesBinding(AppScope::class, boundType = NativeInputStatePublisher::class)
34-
class RealNativeInputStateStore @Inject constructor() :
43+
class RealNativeInputStateStore @Inject constructor(
44+
// Lazy avoids a Dagger cycle: TabRepository's impl (TabDataRepository) injects
45+
// NativeInputStatePublisher to clearTab on tab eviction. Resolving TabRepository
46+
// lazily lets Dagger construct both without circularity. The lazy is only
47+
// dereferenced when `state` is collected for the first time.
48+
private val tabRepository: Lazy<TabRepository>,
49+
) :
3550
NativeInputStateProvider,
3651
NativeInputStatePublisher {
3752

3853
private val flows = ConcurrentHashMap<String, MutableStateFlow<NativeInputState>>()
3954

55+
override val state: Flow<NativeInputState> by lazy {
56+
tabRepository.get().flowSelectedTab
57+
.filterNotNull()
58+
.map { it.tabId }
59+
.distinctUntilChanged()
60+
.flatMapLatest { flowFor(it) }
61+
}
62+
4063
override fun stateForTab(tabId: String): StateFlow<NativeInputState> = flowFor(tabId)
4164

4265
override fun publish(tabId: String, state: NativeInputState) {
@@ -59,5 +82,5 @@ class RealNativeInputStateStore @Inject constructor() :
5982
// value. The native input widget overwrites it on configure, so observers should never see this
6083
// placeholder in practice.
6184
private fun flowFor(tabId: String): MutableStateFlow<NativeInputState> =
62-
flows.computeIfAbsent(tabId) { MutableStateFlow(NativeInputState.zero(tabId)) }
85+
flows.computeIfAbsent(tabId) { MutableStateFlow(NativeInputState.zero()) }
6386
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ class NativeInputModeWidgetViewModel @Inject constructor(
160160
private data class WidgetConfig(
161161
val inputContext: NativeInputState.InputContext = NativeInputState.InputContext.BROWSER,
162162
val inputPosition: NativeInputState.InputPosition = NativeInputState.InputPosition.TOP,
163+
// null = follow the context-derived default; non-null = user (or widget) has set it explicitly
164+
// via setToggleSelection. Reset to null on every configure(), then driven by the widget's
165+
// TabLayout listener.
166+
val toggleSelection: NativeInputState.ToggleSelection? = null,
163167
)
164168

165169
private val widgetConfig = MutableStateFlow(WidgetConfig())
@@ -172,12 +176,12 @@ class NativeInputModeWidgetViewModel @Inject constructor(
172176
duckChatInternal.observeInputScreenUserSettingEnabled(),
173177
widgetConfig,
174178
activeTabId.filterNotNull(),
175-
) { isFeatureEnabled, isUserEnabled, isInputScreenUserSettingEnabled, config, tabId ->
179+
) { isFeatureEnabled, isUserEnabled, isInputScreenUserSettingEnabled, config, _ ->
176180
NativeInputState(
177181
inputMode = getInputMode(isFeatureEnabled && isUserEnabled, isInputScreenUserSettingEnabled),
178182
inputContext = config.inputContext,
179183
inputPosition = config.inputPosition,
180-
tabId = tabId,
184+
toggleSelection = config.toggleSelection ?: NativeInputState.defaultToggleFor(config.inputContext),
181185
)
182186
}.shareIn(
183187
scope = viewModelScope,
@@ -186,8 +190,26 @@ class NativeInputModeWidgetViewModel @Inject constructor(
186190
)
187191

188192
init {
193+
// Publish only the widget-owned fields via update, leaving plugin-owned contributions (added
194+
// later by typed host methods) untouched across widget emissions. This keeps the widget VM
195+
// the single writer for its fields without clobbering anything the publisher.update path
196+
// wrote on behalf of a plugin.
197+
//
198+
// Pair each state with the active tabId via combine so the publish target is captured at
199+
// emission time rather than read separately from activeTabId.value (which could shift
200+
// between emission and read on a fast tab switch).
189201
viewModelScope.launch {
190-
state.collect { nativeInputStatePublisher.publish(it.tabId, it) }
202+
combine(state, activeTabId.filterNotNull()) { snapshot, tabId -> tabId to snapshot }
203+
.collect { (tabId, snapshot) ->
204+
nativeInputStatePublisher.update(tabId) { current ->
205+
current.copy(
206+
inputMode = snapshot.inputMode,
207+
inputContext = snapshot.inputContext,
208+
inputPosition = snapshot.inputPosition,
209+
toggleSelection = snapshot.toggleSelection,
210+
)
211+
}
212+
}
191213
}
192214
}
193215

@@ -220,6 +242,10 @@ class NativeInputModeWidgetViewModel @Inject constructor(
220242
widgetConfig.update { it.copy(inputPosition = position) }
221243
}
222244

245+
fun setToggleSelection(selection: NativeInputState.ToggleSelection) {
246+
widgetConfig.update { it.copy(toggleSelection = selection) }
247+
}
248+
223249
fun configure(tabId: String, isDuckAiMode: Boolean, isBottom: Boolean) {
224250
activeTabId.value = tabId
225251
val context = if (isDuckAiMode) NativeInputState.InputContext.DUCK_AI else NativeInputState.InputContext.BROWSER

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/ModelPickerView.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import com.duckduckgo.common.ui.view.text.DaxTextView
4141
import com.duckduckgo.common.utils.ViewViewModelFactory
4242
import com.duckduckgo.di.scopes.ViewScope
4343
import com.duckduckgo.duckchat.api.nativeinput.NativeInputState.InputContext
44+
import com.duckduckgo.duckchat.api.nativeinput.NativeInputStateProvider
4445
import com.duckduckgo.duckchat.impl.DuckChatConstants.DUCK_AI_FEATURE_PAGE
4546
import com.duckduckgo.duckchat.impl.R
4647
import com.duckduckgo.duckchat.impl.models.AIChatModel
@@ -78,14 +79,21 @@ class ModelPickerView @JvmOverloads constructor(
7879

7980
@Inject lateinit var globalActivityStarter: GlobalActivityStarter
8081

82+
@Inject lateinit var nativeInputStateProvider: NativeInputStateProvider
83+
8184
private val viewModel by lazy {
8285
ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[ModelPickerViewModel::class.java]
8386
}
8487
private val chip: Chip by lazy { findViewById(R.id.modelPickerChip) }
8588
private var stateJob: Job? = null
89+
private var inputContextJob: Job? = null
8690
private var commandJob: Job? = null
8791
private var popupWindow: PopupWindow? = null
8892
private var lastObservedModelId: String? = null
93+
94+
// Mirrors the input context from the per-tab native input state so currentSurface() can be
95+
// read synchronously from popup callbacks. Updated by observeInputContext().
96+
private var lastInputContext: InputContext = InputContext.BROWSER
8997
private lateinit var host: NativeInputHost
9098
override var onMenuShown: (() -> Unit)? = null
9199
override var onMenuDismissed: (() -> Unit)? = null
@@ -131,6 +139,15 @@ class ModelPickerView @JvmOverloads constructor(
131139

132140
viewModel.fetchModels()
133141
observeState()
142+
observeInputContext()
143+
}
144+
145+
private fun observeInputContext() {
146+
val scope = findViewTreeLifecycleOwner()?.lifecycleScope ?: return
147+
inputContextJob?.cancel()
148+
inputContextJob = nativeInputStateProvider.state
149+
.onEach { lastInputContext = it.inputContext }
150+
.launchIn(scope)
134151
}
135152

136153
private fun observeState() {
@@ -165,7 +182,7 @@ class ModelPickerView @JvmOverloads constructor(
165182
}
166183

167184
private fun currentSurface(): ModelPickerViewModel.PickerSurface =
168-
when (host.getInputState().inputContext) {
185+
when (lastInputContext) {
169186
InputContext.DUCK_AI, InputContext.DUCK_AI_CONTEXTUAL -> ModelPickerViewModel.PickerSurface.DUCK_AI_TAB
170187
InputContext.BROWSER -> ModelPickerViewModel.PickerSurface.ADDRESS_BAR
171188
}
@@ -230,6 +247,8 @@ class ModelPickerView @JvmOverloads constructor(
230247
super.onDetachedFromWindow()
231248
stateJob?.cancel()
232249
stateJob = null
250+
inputContextJob?.cancel()
251+
inputContextJob = null
233252
commandJob?.cancel()
234253
commandJob = null
235254
dismissPopup()

0 commit comments

Comments
 (0)