Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9a4e885
Add Fire-all action to Duck.ai chat history screen
GerardPaligot May 13, 2026
1f63b2d
Add select mode to Duck.ai chat history screen
GerardPaligot May 13, 2026
0e3027d
Wire chat-history Delete-selected through the data-clearing plugin chain
GerardPaligot May 14, 2026
5b7b751
Spare Pinned chats on Fire-all by routing N≥2 through Selected
GerardPaligot May 15, 2026
5689b52
Match Duck.ai tabs by chatID query param, not full URL
GerardPaligot May 15, 2026
15bec23
Cleanup fire implementation
GerardPaligot May 15, 2026
f5a8b72
Wire per-row Delete in chat history overflow menu
GerardPaligot May 15, 2026
92627f3
Tighten FireDialogOrigin.ChatHistory shape
GerardPaligot May 15, 2026
4bebf91
Merge DuckChats.Single into DuckChats.Selected
GerardPaligot May 15, 2026
2117782
Fix flaky ChatHistoryViewModelTest tests by using awaitInitialLoaded
GerardPaligot May 15, 2026
4ebab49
Collapse ChatHistoryViewModel UI flows into a single state holder
GerardPaligot May 16, 2026
5b1c539
Rename _then test cases added on this branch to backtick natural English
GerardPaligot May 18, 2026
d1e1dd7
Stop ChatHistory fire dialog from restarting the app process
GerardPaligot May 18, 2026
c04c334
Reset back-press callback on Loading and Empty in ChatHistoryFragment
GerardPaligot May 18, 2026
8528037
Reset toolbar nav CD on default toolbar and re-check fire dialog tag …
GerardPaligot May 18, 2026
e86a98b
Drop chat-history confirmation on EVENT_CLEAR_WITHOUT_RESTART_STARTED…
GerardPaligot May 18, 2026
c45e6a0
Align select-all unchecked indicator size with checked state
GerardPaligot May 18, 2026
595e894
Flip chat-history row icon when selection toggles
GerardPaligot May 18, 2026
8c012a7
Apply spotless formatting to multi-line when arm
GerardPaligot May 18, 2026
29027b4
Cleanup after a mistake with latest rebase
GerardPaligot May 19, 2026
d1adba9
Include Pinned chats in Fire-all from chat history
GerardPaligot May 19, 2026
3aa237f
Split chat-history bulk delete off the fire-button path
GerardPaligot May 19, 2026
b3569d9
Always confirm Fire-all from chat history
GerardPaligot May 19, 2026
d458161
Reconcile chat-history selection inside reduce
GerardPaligot May 19, 2026
3f58bc3
Gate chat-history dispatch on showClearDuckAIChatHistory
GerardPaligot May 19, 2026
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
17 changes: 13 additions & 4 deletions app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,20 @@ class DataClearing @Inject constructor(
val clearDataResult = clearDataAction.clearDataForSpecificDomains(visitedSites)
val tabUrl = tabRepository.getTab(tabId)?.url

clearDuckAiChatIfNeeded(tabUrl)
clearContextualChatDataIfNeeded(tabId)
navigationHistory.removeHistoryForTab(tabId)

// Reset this tab's URL before dispatching the chat clear: the tabs-cleanup plugin matches
// tabs by chatID, and we don't want this tab caught by that match — it stays open with a
// new chat URL. Other tabs at the same chatID (duplicates) do get closed, which is desired.
if (replaceCurrentTab) {
val url = getNewTabUrl(tabUrl)
tabOperations.replaceTabWithNewTab(tabId, url)
} else {
tabRepository.deleteTabs(listOf(tabId))
}

clearDuckAiChatIfNeeded(tabUrl)
clearContextualChatDataIfNeeded(tabId)
navigationHistory.removeHistoryForTab(tabId)

logcat { "Single tab clear completed for tab: $tabId" }
return clearDataResult
}
Expand Down Expand Up @@ -233,6 +236,12 @@ class DataClearing @Inject constructor(
return fireDataStore.getAutomaticClearOptions().isNotEmpty()
}

override suspend fun clearSelectedDuckAiChats(chatUrls: Set<String>) {
if (chatUrls.isEmpty()) return
if (!duckAiFeatureState.showClearDuckAIChatHistory.value) return
dataClearingTrigger.clearData(setOf(ClearableData.DuckChats.Selected(chatUrls)))
}

/**
* Performs granular data clearing based on the provided options
* @return true if process needs to be restarted
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2026 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.app.fire

import android.net.Uri
import androidx.core.net.toUri
import com.duckduckgo.app.tabs.model.TabRepository
import com.duckduckgo.dataclearing.api.plugin.ClearableData
import com.duckduckgo.dataclearing.api.plugin.DataClearingPlugin
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.api.DuckChat
import com.squareup.anvil.annotations.ContributesMultibinding
import logcat.logcat
import javax.inject.Inject

/** Closes Duck.ai tabs left pointing at cleared chat URLs. Never touches non-Duck.ai tabs. */
@ContributesMultibinding(AppScope::class)
class DuckAiTabsCleanupPlugin @Inject constructor(
private val tabRepository: TabRepository,
private val duckChat: DuckChat,
) : DataClearingPlugin {

override suspend fun onClearData(types: Set<ClearableData>) {
types.forEach { type ->
when (type) {
is ClearableData.DuckChats.All -> closeAllDuckAiTabs()
is ClearableData.DuckChats.Selected -> closeTabsMatching(type.chatUrls)
else -> { /* not handled by this plugin */ }
}
}
}

private suspend fun closeAllDuckAiTabs() {
val ids = tabRepository.getTabs()
.filter { tab -> tab.url?.toUri()?.let(duckChat::isDuckChatUrl) == true }
.map { it.tabId }
if (ids.isNotEmpty()) {
logcat { "Closing ${ids.size} open Duck.ai tab(s) after chat clear" }
tabRepository.deleteTabs(ids)
}
}

/**
* Match by `chatID` query param rather than full URL equality — tabs drift (server redirects,
* extra query params accumulated during the session) so multiple tabs of the same chat would
* otherwise miss the match.
*/
private suspend fun closeTabsMatching(chatUrls: Set<String>) {
if (chatUrls.isEmpty()) return
val targetChatIds = chatUrls.mapNotNullTo(mutableSetOf()) { it.toUri().chatIdOrNull() }
if (targetChatIds.isEmpty()) return
val ids = tabRepository.getTabs()
.filter { tab -> tab.url?.toUri()?.chatIdOrNull() in targetChatIds }
.map { it.tabId }
if (ids.isNotEmpty()) {
logcat { "Closing ${ids.size} Duck.ai tab(s) matching the cleared subset" }
tabRepository.deleteTabs(ids)
}
}

private fun Uri.chatIdOrNull(): String? {
if (!duckChat.isDuckChatUrl(this)) return null
return getQueryParameter("chatID")?.takeIf { it.isNotBlank() }

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: this is the third copy of "chatID" in the codebase — RealDuckChat has a private CHAT_ID_QUERY_NAME const, NativeInputManager hardcodes it too. can we promote it so we stop duplicating it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm opening an api proposal for this and I'll open a new PR for that.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ interface ManualDataClearing {
*/
suspend fun clearDataUsingManualFireOptions(shouldRestartIfRequired: Boolean = false, wasAppUsedSinceLastClear: Boolean = false)

/** Deletes only the chats addressed by [chatUrls] and closes any browser tabs pointing at them. */
suspend fun clearSelectedDuckAiChats(chatUrls: Set<String>)

/**
* Clears all data associated with tab:
* site browsing data (via WebStorageCompat), tab-specific history,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,13 @@ private const val BOTTOM_SHEET_MAX_WIDTH_DP = 640
private const val NO_MAX_WIDTH = -1
private const val ARG_ORIGIN = "origin"
private const val ARG_TAB_ID = "tabId"
private const val ARG_SELECTED_CHAT_URLS = "selectedChatUrls"
internal const val ORIGIN_BROWSER = "Browser"
internal const val ORIGIN_SETTINGS = "Settings"
internal const val ORIGIN_TAB_SWITCHER = "TabSwitcher"
internal const val ORIGIN_DUCK_AI_CONTEXTUAL_CHAT = "DuckAiContextualChat"
internal const val ORIGIN_HATCH = "Hatch"
internal const val ORIGIN_CHAT_HISTORY = "ChatHistory"

@InjectWith(FragmentScope::class)
class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog {
Expand Down Expand Up @@ -174,18 +176,26 @@ class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog {
private fun setupLayout() {
binding.deleteAllPrimaryButton.setOnClickListener {
hideDialog()
viewModel.onDeleteAllClicked()
dispatchDeleteAll()
}
binding.deleteAllSecondaryButton.setOnClickListener {
hideDialog()
viewModel.onDeleteAllClicked()
dispatchDeleteAll()
}
binding.deleteThisTabButton.setOnClickListener {
hideDialog()
viewModel.onDeleteThisTabClicked()
}
}

private fun dispatchDeleteAll() {
if (arguments?.getString(ARG_ORIGIN) == ORIGIN_CHAT_HISTORY) {
viewModel.onDeleteSelectedChatsClicked()
} else {
viewModel.onDeleteAllClicked()
}
}

private fun configureBottomSheet() {
(dialog as? BottomSheetDialog)?.behavior?.apply {
state = BottomSheetBehavior.STATE_EXPANDED
Expand Down Expand Up @@ -271,14 +281,11 @@ class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog {
binding.fireIcon.gone()
}

val titleRes = if (state.stateData.isDuckAiTab && state.isDeleteThisTabButtonVisible) {
R.string.singleTabFireDialogTitleDuckAi
} else if (state.stateData.isDuckAiChatsSelected) {
R.string.singleTabFireDialogTitleWithChats
} else {
R.string.singleTabFireDialogTitle
binding.dialogTitle.text = when (val source = state.stateData.titleSource) {
is SingleTabFireDialogViewModel.TitleSource.Static -> requireContext().getString(source.resId)
is SingleTabFireDialogViewModel.TitleSource.Plural ->
resources.getQuantityString(source.pluralsId, source.count, source.count)
}
binding.dialogTitle.text = requireContext().getString(titleRes)

if (state.isDeleteThisTabButtonVisible) {
binding.deleteThisTabButton.show()
Expand Down Expand Up @@ -439,6 +446,10 @@ class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog {
FireDialogProvider.FireDialogOrigin.Browser
}
}
ORIGIN_CHAT_HISTORY -> {
val urls = arguments?.getStringArrayList(ARG_SELECTED_CHAT_URLS)?.toSet().orEmpty()
FireDialogProvider.FireDialogOrigin.ChatHistory(selectedChatUrls = urls)
}
else -> FireDialogProvider.FireDialogOrigin.Browser
}
}
Expand All @@ -449,6 +460,8 @@ class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog {
arguments = bundleOf(
ARG_ORIGIN to origin.tag(),
ARG_TAB_ID to (origin as? FireDialogProvider.FireDialogOrigin.Hatch)?.tabId,
ARG_SELECTED_CHAT_URLS to (origin as? FireDialogProvider.FireDialogOrigin.ChatHistory)
?.selectedChatUrls?.let { ArrayList(it) },
)
}
}
Expand All @@ -459,6 +472,7 @@ class SingleTabFireDialog : BottomSheetDialogFragment(), FireDialog {
FireDialogProvider.FireDialogOrigin.TabSwitcher -> ORIGIN_TAB_SWITCHER
FireDialogProvider.FireDialogOrigin.DuckAiContextualChat -> ORIGIN_DUCK_AI_CONTEXTUAL_CHAT
is FireDialogProvider.FireDialogOrigin.Hatch -> ORIGIN_HATCH
is FireDialogProvider.FireDialogOrigin.ChatHistory -> ORIGIN_CHAT_HISTORY
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.app.browser.R
import com.duckduckgo.app.browser.api.WebViewCapabilityChecker
import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.DeleteBrowsingData
import com.duckduckgo.app.fire.ManualDataClearing
Expand Down Expand Up @@ -163,6 +164,27 @@ class SingleTabFireDialogViewModel @Inject constructor(
}
}

fun onDeleteSelectedChatsClicked() {
val selectedChatUrls = (origin.value as? FireDialogOrigin.ChatHistory)?.selectedChatUrls ?: return
viewModelScope.launch {
shouldRestartAfterClearing = false
command.send(Command.OnClearStarted)

val fireAnimationEnabled = withContext(dispatcherProvider.io()) {
settingsDataStore.fireAnimationEnabled
}
if (fireAnimationEnabled) {
command.send(Command.PlayAnimation)
}

withContext(dispatcherProvider.io()) {
dataClearing.clearSelectedDuckAiChats(selectedChatUrls)
}

command.send(Command.ClearingComplete)
}
}

fun onDeleteThisTabClicked() {
viewModelScope.launch {
shouldRestartAfterClearing = false
Expand Down Expand Up @@ -230,28 +252,51 @@ class SingleTabFireDialogViewModel @Inject constructor(
}

private suspend fun mapToViewState(dialogOrigin: FireDialogOrigin): ViewState.Loaded {
// Non-tab origins skip the tab/download/WebView probes — there's no tab to reason about.
val isTabAware = dialogOrigin !is FireDialogOrigin.ChatHistory

val isDuckAiChatsSelected =
fireDataStore.isManualClearOptionSelected(FireClearOption.DUCKAI_CHATS)
val isDeleteBrowsingDataSupported = webViewCapabilityChecker.isSupported(DeleteBrowsingData)
val shownCount = settingsDataStore.singleTabFireDialogShownCount
val downloads = downloadsRepository.getDownloads()
val targetTabUrl = resolveTargetTabUrl(dialogOrigin)
isTabAware && fireDataStore.isManualClearOptionSelected(FireClearOption.DUCKAI_CHATS)
val isDeleteBrowsingDataSupported = isTabAware && webViewCapabilityChecker.isSupported(DeleteBrowsingData)
val downloads = if (isTabAware) downloadsRepository.getDownloads() else emptyList()
val targetTabUrl = if (isTabAware) resolveTargetTabUrl(dialogOrigin) else null
val tabCount = if (isTabAware) tabRepository.getOpenTabCount() else 0
val isDuckAiTab = dialogOrigin == DuckAiContextualChat ||
targetTabUrl?.let { duckChat.isDuckChatUrl(it.toUri()) } ?: false
val tabCount = tabRepository.getOpenTabCount()
targetTabUrl?.let { duckChat.isDuckChatUrl(it.toUri()) } == true
val isFireAnimationUpdateEnabled = withContext(dispatcherProvider.io()) {
brandDesignUpdateToggles.fireAnimationUpdate().isEnabled()
}
val isDeleteThisTabAvailable = (isDeleteBrowsingDataSupported && dialogOrigin == Browser) ||
dialogOrigin == DuckAiContextualChat ||
dialogOrigin is Hatch
val shownCount = settingsDataStore.singleTabFireDialogShownCount

val titleSource: TitleSource = when (dialogOrigin) {
is FireDialogOrigin.ChatHistory -> TitleSource.Plural(
pluralsId = R.plurals.fireDialogDeleteCountTitle,
count = dialogOrigin.count,
)
else -> {
val titleResId = when {
isDuckAiTab && isDeleteThisTabAvailable -> R.string.singleTabFireDialogTitleDuckAi
isDuckAiChatsSelected -> R.string.singleTabFireDialogTitleWithChats
else -> R.string.singleTabFireDialogTitle
}
TitleSource.Static(titleResId)
}
}

return ViewState.Loaded(
stateData = ViewState.Loaded.StateData(
isDuckAiChatsSelected = isDuckAiChatsSelected,
isSingleTabEnabled = isDeleteBrowsingDataSupported,
isDuckAiTab = isDuckAiTab,
tabCount = tabCount,
isSiteDataSubtitleEligible = shownCount < DIALOG_WARNING_MESSAGE_SHOWN_LIMIT,
isSiteDataSubtitleEligible = isTabAware && shownCount < DIALOG_WARNING_MESSAGE_SHOWN_LIMIT,
isDownloadsSubtitleEligible = downloads.any { download -> download.downloadStatus == DownloadStatus.STARTED },
isFirePictogramVisible = settingsDataStore.fireAnimationEnabled,
isFireAnimationUpdateEnabled = isFireAnimationUpdateEnabled,
titleSource = titleSource,
),
origin = dialogOrigin,
)
Expand Down Expand Up @@ -322,10 +367,16 @@ class SingleTabFireDialogViewModel @Inject constructor(
val isDownloadsSubtitleEligible: Boolean = false,
val isFirePictogramVisible: Boolean = true,
val isFireAnimationUpdateEnabled: Boolean = false,
val titleSource: TitleSource = TitleSource.Static(R.string.singleTabFireDialogTitle),
)
}
}

sealed class TitleSource {
data class Static(val resId: Int) : TitleSource()
data class Plural(val pluralsId: Int, val count: Int) : TitleSource()
}

sealed class Command {
data object PlayAnimation : Command()
data object ClearingComplete : Command()
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values/donottranslate.xml
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,9 @@
<!-- Fire animation rollout: relabel HeroFire to "Inferno Classic" when fireAnimationUpdate is on — pending translation -->
<string name="settingsHeroFireAnimationClassic">Inferno Classic</string>
<string name="settingsInfernoAnimation">Inferno</string>

<plurals name="fireDialogDeleteCountTitle">
<item quantity="one" instruction="%1$d is the number of chats the user is about to delete.">Delete %1$d chat?</item>
<item quantity="other" instruction="%1$d is the number of chats the user is about to delete.">Delete %1$d chats?</item>
</plurals>
</resources>
Loading
Loading