Skip to content

Commit 20310fd

Browse files
malmsteinclaude
andauthored
Add ActivePlugin point for Chat-tab contributions (#8948)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1214157224317277/task/1215897994835019 Tech Design URL (if applicable): https://app.asana.com/1/137249556945/task/1215906519502283 ### Description Introduces `NativeInputChatTabItemPlugin`, an `ActivePluginPoint` that lets modules outside `duckchat-impl` contribute their own item(s) to the native-input Chat-tab suggestions list (the list shown under the **Chat** tab when the input is focused in *Search & Duck.ai* mode), gated by remote config and without depending on `duckchat-impl`. The full design lives in the [Tech Design](https://app.asana.com/1/137249556945/task/1215906519502283). Highlights: - **API (`duckchat-api`):** `NativeInputChatTabItemPlugin` is a factory for a per-binding `NativeInputChatTabItem`, which owns a `RecyclerView.Adapter` slotted into the existing Chat-tab `ConcatAdapter`. A plugin declares via `supportsQuery` whether it participates in filtering: `false` items are zero-state (shown only while the query is empty, hidden once the user types); `true` items stay and receive `onQueryChanged`. The interface lives in `duckchat-api` so it travels with native input when it moves to its own module. - **Plugin point (`duckchat-impl`):** `pluginPointNativeInputChatTabItemPlugin` (default enabled); each contributed plugin is additionally gated by its own `@ContributesActivePlugin` flag. Ordering comes from `@ContributesActivePlugin(priority = …)`. - **Host (`duckchat-impl`):** `NativeInputChatSuggestionsBinder` loads the enabled plugins and inserts each item's adapter at the **top** of the `ConcatAdapter` in plugin-point order, above the built-in sections (which are left untouched). `submit()` forwards the query only to query-aware items and folds visible plugin content into the overlay's `hasContent`. Loading is decoupled from `submit`, so the latest `submit` is replayed once plugins finish loading. `NativeInputModeWidget` owns a per-binding `CoroutineScope` handed to each item, cancelled at teardown. This is the first step toward later migrating the existing sections (chat history, "View all Chats", URL suggestions, "Search for …") onto the same plugin point — see the Tech Design. **No user-facing change:** this PR adds the mechanism only; there are no contributors yet (an internal-only example card was used to validate the contract during development and has been removed). ### Steps to test this PR _Plugin mechanism (no user-visible change)_ - [ ] `./gradlew :duckchat-impl:testDebugUnitTest --tests "com.duckduckgo.duckchat.impl.ui.nativeinput.views.NativeInputChatSuggestionsBinderTest"` passes — covers top insertion in priority order, query forwarding only to `supportsQuery == true` items, non-query items hidden while typing and restored on clear, `hasContent` aggregation, and the load-vs-submit race (replay). - [ ] App builds and the Chat tab behaves exactly as before, since no module contributes a plugin yet. ### UI changes | Before | After | | ------ | ----- | | _No UI changes — mechanism only, no contributors yet_ | | <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches native input overlay visibility and keystroke-driven state; behavior is heavily unit-tested but affects a core Duck.ai input UX path. > > **Overview** > Adds an **ActivePlugin** extension point so other modules can contribute rows to the native-input **Chat** tab suggestions list without depending on `duckchat-impl`. The API exposes `NativeInputChatTabItemPlugin` / `NativeInputChatTabItem`, plus `SingleViewChatTabItem` for single-card contributions, and extends **`DuckChatInputModeState`** with a **`chatQuery`** `StateFlow` (kept in sync from `InputModeWidget` via **`setChatQuery`**). > > The host **`NativeInputChatSuggestionsBinder`** loads enabled plugins into the top of the existing **`ConcatAdapter`**, observes adapter changes to recompute overlay **`hasContent`** (using live **`chatQuery`** so zero-state cards can hide without flickering the overlay closed while the user types), and **`NativeInputModeWidget`** owns a scoped coroutine job for plugin loading/teardown. An internal-only **`ExampleMessageCardPlugin`** demonstrates the pattern; unit tests cover ordering, overlay aggregation, load-after-submit, dismiss, and clear races. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9ad5e42. 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: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 282dce1 commit 20310fd

14 files changed

Lines changed: 761 additions & 17 deletions

File tree

duckchat/duckchat-api/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies {
3434

3535
implementation KotlinX.coroutines.core
3636
implementation AndroidX.appCompat
37+
implementation AndroidX.recyclerView
3738

3839
coreLibraryDesugaring Android.tools.desugarJdkLibs
3940

duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatInputModeState.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ interface DuckChatInputModeState {
3535
* no Duck.ai input widget is attached.
3636
*/
3737
val displayedMode: StateFlow<InputMode>
38+
39+
/**
40+
* The current Chat-tab query — the text typed while the Chat tab is the input target.
41+
*
42+
* High-frequency (updates per keystroke), kept as its own flow so it doesn't churn
43+
* [displayedMode] consumers. Holds [""] when the Chat tab isn't the target (Search tab selected
44+
* or no widget attached), mirroring how [displayedMode] resets to [InputMode.SEARCH]. A sibling
45+
* `searchQuery` can follow the same pattern for the Search tab.
46+
*/
47+
val chatQuery: StateFlow<String>
3848
}
3949

4050
/**
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.api.inputscreen
18+
19+
import android.content.Context
20+
import androidx.recyclerview.widget.RecyclerView
21+
import com.duckduckgo.common.utils.plugins.ActivePlugin
22+
import kotlinx.coroutines.CoroutineScope
23+
24+
/**
25+
* Lets a module contribute its own item(s) to the native-input Chat-tab suggestions list without
26+
* depending on `duckchat-impl`. The host slots each contribution's [RecyclerView.Adapter]s into the
27+
* Chat-tab's `ConcatAdapter`.
28+
*
29+
* The plugin is a singleton in the DI graph (it is multibound); it acts as a factory because the
30+
* contributed adapters are stateful and bound to a single Chat-tab presentation. Implement with
31+
* `@ContributesActivePlugin`, set `parentFeatureName = "pluginPointNativeInputChatTabItemPlugin"`, and
32+
* set `priority` to one of the [companion] constants to order against other contributions.
33+
*
34+
* For the common case of a single static view (e.g. a card), extend [SingleViewChatTabItem] instead
35+
* of implementing [NativeInputChatTabItem] directly.
36+
*/
37+
interface NativeInputChatTabItemPlugin : ActivePlugin {
38+
39+
/**
40+
* Create a fresh item bound to one Chat-tab presentation.
41+
*
42+
* @param context a view context suitable for inflating the item's views.
43+
* @param scope cancelled by the host when the Chat tab is torn down. Use it to collect data into
44+
* the returned adapters; do not retain it beyond the returned [NativeInputChatTabItem].
45+
*/
46+
fun create(context: Context, scope: CoroutineScope): NativeInputChatTabItem
47+
48+
companion object {
49+
// Order of contributions, top to bottom. Lower value comes first (higher in the list); gaps
50+
// leave room to insert new contributions; equal values are allowed and tie-break by class name.
51+
// Mirrors NewTabPageSectionPlugin. The built-in sections will claim their own constants here as
52+
// they migrate onto this plugin point; until then they render below all contributions.
53+
const val PRIORITY_PROMO = 100
54+
const val PRIORITY_CHAT_HISTORY = 200
55+
const val PRIORITY_URL_SUGGESTIONS = 300
56+
const val PRIORITY_SEARCH_FOR = 400
57+
}
58+
}
59+
60+
/**
61+
* A single contribution to the Chat-tab list. Purely a rendering surface: it owns its adapter(s) and
62+
* view holders, and the host only positions them.
63+
*
64+
* Input state the item needs to decide its rows — the current query, the displayed mode — is read from
65+
* the shared state in this module (e.g. [com.duckduckgo.duckchat.api.DuckChatInputModeState.chatQuery]
66+
* and `displayedMode`), not pushed in here. An item observes that state (combining it with its own
67+
* async data) and updates its adapter(s); an item with no rows contributes no content and does not keep
68+
* the suggestions overlay open. For the common single-view card see [SingleViewChatTabItem].
69+
*/
70+
interface NativeInputChatTabItem {
71+
72+
/**
73+
* The adapters slotted into the host's `ConcatAdapter`, in order. Most items contribute a single
74+
* adapter; returning several lets one item own multiple sections without nesting `ConcatAdapter`s.
75+
* The plugin fully owns each adapter's item count and view holders.
76+
*/
77+
val adapters: List<RecyclerView.Adapter<*>>
78+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.api.inputscreen
18+
19+
import android.view.View
20+
import android.view.ViewGroup
21+
import androidx.recyclerview.widget.RecyclerView
22+
import kotlinx.coroutines.CoroutineScope
23+
import kotlinx.coroutines.flow.Flow
24+
import kotlinx.coroutines.flow.MutableStateFlow
25+
import kotlinx.coroutines.flow.combine
26+
import kotlinx.coroutines.launch
27+
28+
/**
29+
* Convenience [NativeInputChatTabItem] for the common case of a single, static view (e.g. a card).
30+
*
31+
* Provide the view in [onCreateView] and a [visible] flow that drives whether the row is shown. The
32+
* flow is the single knob: compose it from whatever state the item cares about — e.g.
33+
* `chatQuery.map { it.isEmpty() }` for a zero-state card that hides while the user types, the item's
34+
* own data for a state-driven card, both combined, or `flowOf(true)` to always show. [hide] is a
35+
* sticky override (e.g. on dismiss) that wins over [visible] for the rest of the presentation.
36+
*
37+
* Items that need multiple rows, view recycling across many items, or several sections should
38+
* implement [NativeInputChatTabItem] directly.
39+
*/
40+
abstract class SingleViewChatTabItem(
41+
visible: Flow<Boolean>,
42+
scope: CoroutineScope,
43+
) : NativeInputChatTabItem {
44+
45+
/** Create the view shown as the single row. Called once; [parent] supplies the context. */
46+
abstract fun onCreateView(parent: ViewGroup): View
47+
48+
private val singleViewAdapter = SingleViewAdapter()
49+
private val dismissed = MutableStateFlow(false)
50+
51+
final override val adapters: List<RecyclerView.Adapter<*>> = listOf(singleViewAdapter)
52+
53+
init {
54+
scope.launch {
55+
combine(visible, dismissed) { show, gone -> show && !gone }
56+
.collect { singleViewAdapter.setVisible(it) }
57+
}
58+
}
59+
60+
/** Permanently hide this item for the current presentation (e.g. on dismiss); wins over [visible]. */
61+
protected fun hide() {
62+
dismissed.value = true
63+
}
64+
65+
private inner class SingleViewAdapter : RecyclerView.Adapter<SingleViewHolder>() {
66+
private var visible = false
67+
68+
fun setVisible(value: Boolean) {
69+
if (value == visible) return
70+
visible = value
71+
if (value) notifyItemInserted(0) else notifyItemRemoved(0)
72+
}
73+
74+
override fun getItemCount(): Int = if (visible) 1 else 0
75+
76+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SingleViewHolder {
77+
val view = onCreateView(parent).apply {
78+
if (layoutParams == null) {
79+
layoutParams = RecyclerView.LayoutParams(
80+
ViewGroup.LayoutParams.MATCH_PARENT,
81+
ViewGroup.LayoutParams.WRAP_CONTENT,
82+
)
83+
}
84+
}
85+
return SingleViewHolder(view)
86+
}
87+
88+
override fun onBindViewHolder(holder: SingleViewHolder, position: Int) = Unit
89+
}
90+
91+
private class SingleViewHolder(view: View) : RecyclerView.ViewHolder(view)
92+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.api.inputscreen
18+
19+
import android.view.View
20+
import android.view.ViewGroup
21+
import androidx.test.ext.junit.runners.AndroidJUnit4
22+
import kotlinx.coroutines.CoroutineScope
23+
import kotlinx.coroutines.flow.Flow
24+
import kotlinx.coroutines.flow.MutableStateFlow
25+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
26+
import kotlinx.coroutines.test.runTest
27+
import org.junit.Assert.assertEquals
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
31+
@RunWith(AndroidJUnit4::class)
32+
class SingleViewChatTabItemTest {
33+
34+
private class TestItem(visible: Flow<Boolean>, scope: CoroutineScope) : SingleViewChatTabItem(visible, scope) {
35+
override fun onCreateView(parent: ViewGroup): View = View(parent.context)
36+
fun dismiss() = hide()
37+
}
38+
39+
private fun NativeInputChatTabItem.rows(): Int = adapters.single().itemCount
40+
41+
@Test
42+
fun whenVisibleTrueThenRowShown() = runTest {
43+
val item = TestItem(MutableStateFlow(true), CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
44+
45+
assertEquals(1, item.rows())
46+
}
47+
48+
@Test
49+
fun whenVisibleFalseThenNoRow() = runTest {
50+
val item = TestItem(MutableStateFlow(false), CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
51+
52+
assertEquals(0, item.rows())
53+
}
54+
55+
@Test
56+
fun whenVisibleTogglesThenRowToggles() = runTest {
57+
val visible = MutableStateFlow(true)
58+
val item = TestItem(visible, CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
59+
assertEquals(1, item.rows())
60+
61+
visible.value = false
62+
assertEquals(0, item.rows())
63+
64+
visible.value = true
65+
assertEquals(1, item.rows())
66+
}
67+
68+
@Test
69+
fun whenDismissedThenStaysHiddenEvenWhenVisibleTrue() = runTest {
70+
val visible = MutableStateFlow(true)
71+
val item = TestItem(visible, CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
72+
assertEquals(1, item.rows())
73+
74+
item.dismiss()
75+
assertEquals(0, item.rows())
76+
77+
// A later visible=true must not bring it back.
78+
visible.value = false
79+
visible.value = true
80+
assertEquals(0, item.rows())
81+
}
82+
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ interface DuckChatInternal : DuckChat {
126126
*/
127127
fun setSelectedMode(mode: InputMode)
128128

129+
/**
130+
* Updates the live Chat-tab query. Called by [InputModeWidget] as the user types on the Chat tab,
131+
* and reset to [""] when the Chat tab isn't the target, to keep [DuckChatInputModeState.chatQuery]
132+
* in sync with the actual widget state.
133+
*/
134+
fun setChatQuery(query: String)
135+
129136
/**
130137
* Observes whether DuckChat is user enabled or disabled.
131138
*/
@@ -397,6 +404,7 @@ class RealDuckChat @Inject constructor(
397404
private val _showContextualMode = MutableStateFlow(false)
398405
private val _allowDuckAiAsDigitalAssistant = MutableStateFlow(false)
399406
private val _displayedMode = MutableStateFlow(InputMode.SEARCH)
407+
private val _chatQuery = MutableStateFlow("")
400408

401409
private val jsonAdapter: JsonAdapter<DuckChatSettingJson> by lazy {
402410
moshi.adapter(DuckChatSettingJson::class.java)
@@ -827,6 +835,12 @@ class RealDuckChat @Inject constructor(
827835
_displayedMode.value = mode
828836
}
829837

838+
override val chatQuery: StateFlow<String> = _chatQuery.asStateFlow()
839+
840+
override fun setChatQuery(query: String) {
841+
_chatQuery.value = query
842+
}
843+
830844
private suspend fun hasActiveSession(): Boolean {
831845
val now = System.currentTimeMillis()
832846
val lastSession = duckChatFeatureRepository.lastSessionTimestamp()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.inputscreen.ui.suggestions
18+
19+
import com.duckduckgo.anvil.annotations.ContributesActivePluginPoint
20+
import com.duckduckgo.di.scopes.AppScope
21+
import com.duckduckgo.duckchat.api.inputscreen.NativeInputChatTabItemPlugin
22+
23+
/**
24+
* Active plugin point for [NativeInputChatTabItemPlugin]. The generated point is gated by the
25+
* "pluginPointNativeInputChatTabItemPlugin" remote feature flag (default enabled); each contributed
26+
* plugin is additionally gated by its own `@ContributesActivePlugin` flag.
27+
*/
28+
@ContributesActivePluginPoint(
29+
scope = AppScope::class,
30+
boundType = NativeInputChatTabItemPlugin::class,
31+
featureName = "pluginPointNativeInputChatTabItemPlugin",
32+
)
33+
private interface NativeInputChatTabItemPluginPointTrigger

0 commit comments

Comments
 (0)