Skip to content

Commit 5f8b947

Browse files
Wire per-row Rename in chat history overflow menu (#8599)
Task/Issue URL: https://app.asana.com/1/137249556945/task/1214820120386822 ### Description Wires the **Rename** action on the Duck.ai chat history screen so it actually edits a conversation's title. ### Steps to test this PR > [!NOTE] > Prerequisites: > - [ ] Install Internal Debug. > - [ ] In Settings → Developer Settings → Feature Flags, confirm `duckAiChatHistory` (`self`, `historyScreen`) is ON and `duckChat → useNativeStorageChatData` is ON. > - [ ] Create at least 2 chats in Duck.ai so there's something to rename. _Happy path_ - [ ] Open the Chats screen → tap the 3-dot on any row → tap **Rename** → confirm the Rename screen opens with the current title preselected and the keyboard up. - [ ] Edit the title → confirm the Save (check) action enables only when the field is non-blank AND different from the original. - [ ] Tap Save → confirm the screen closes and the row in the list now shows the new title without a manual refresh. - [ ] Open web sidebar and check the chat is renamed there too _Guards_ - [ ] Open Rename and clear the field → confirm Save stays disabled. - [ ] Open Rename and reset the field back to the original title (including the same value with surrounding whitespace) → confirm Save stays disabled. - [ ] Open Rename and tap the toolbar back arrow / system back → confirm the screen closes and the title is unchanged in the list. ### UI changes | Before | After | | ------ | ----- | <img width="270" height="600" alt="image" src="https://github.com/user-attachments/assets/6042c3fe-7aee-4a67-b9dd-bdda830215a6" /> | <img width="270" height="600" alt="image" src="https://github.com/user-attachments/assets/cc76b4e3-99b3-4978-97ae-1859b21720ca" />
1 parent b17aaab commit 5f8b947

13 files changed

Lines changed: 482 additions & 1 deletion

File tree

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
119119
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
120120
.onEach(::render)
121121
.launchIn(viewLifecycleOwner.lifecycleScope)
122+
123+
viewModel.navigationEvents
124+
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
125+
.onEach(::onNavigationEvent)
126+
.launchIn(viewLifecycleOwner.lifecycleScope)
127+
}
128+
129+
private fun onNavigationEvent(event: ChatHistoryViewModel.NavigationEvent) {
130+
when (event) {
131+
is ChatHistoryViewModel.NavigationEvent.OpenRename -> openRenameScreen(event.chatId, event.currentTitle)
132+
}
133+
}
134+
135+
private fun openRenameScreen(chatId: String, currentTitle: String) {
136+
parentFragmentManager.beginTransaction()
137+
.replace(R.id.chatHistoryFragmentContainer, RenameChatFragment.newInstance(chatId, currentTitle))
138+
.addToBackStack(null)
139+
.commit()
122140
}
123141

124142
private fun render(state: ChatHistoryUiState) {
@@ -269,7 +287,9 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
269287
val popup = PopupMenu(layoutInflater, R.layout.popup_chat_history_row)
270288
val view = popup.contentView
271289
popup.onMenuItemClicked(view.findViewById(R.id.pin)) { showComingSoonSnackbar() }
272-
popup.onMenuItemClicked(view.findViewById(R.id.rename)) { showComingSoonSnackbar() }
290+
popup.onMenuItemClicked(view.findViewById(R.id.rename)) {
291+
viewModel.onRenameRequested(item.chatId, item.displayTitle)
292+
}
273293
popup.onMenuItemClicked(view.findViewById(R.id.download)) { showComingSoonSnackbar() }
274294
popup.onMenuItemClicked(view.findViewById(R.id.delete)) { viewModel.onDeleteSingleChat(item.chatId) }
275295
popup.show(binding.root, anchor)

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryRepository.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ interface ChatHistoryRepository {
3535

3636
suspend fun deleteChat(chatId: String)
3737
suspend fun deleteAllChats()
38+
suspend fun renameChat(chatId: String, newTitle: String): Boolean
3839
}
3940

4041
@ContributesBinding(AppScope::class)
@@ -59,6 +60,9 @@ class RealChatHistoryRepository @Inject constructor(
5960
withContext(dispatchers.io()) { chatStore.deleteAllChats() }
6061
}
6162

63+
override suspend fun renameChat(chatId: String, newTitle: String): Boolean =
64+
withContext(dispatchers.io()) { chatStore.renameChat(chatId, newTitle) }
65+
6266
private fun toChatHistoryItem(chat: DuckAiChat): ChatHistoryItem = ChatHistoryItem(
6367
chatId = chat.chatId,
6468
displayTitle = chat.title.takeIf { it.isNotBlank() && it != UPSTREAM_UNTITLED } ?: fallbackTitle,

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,15 @@ import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.Loaded
2929
import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.Mode
3030
import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.PendingConfirmation
3131
import kotlinx.coroutines.CoroutineScope
32+
import kotlinx.coroutines.channels.BufferOverflow
33+
import kotlinx.coroutines.channels.Channel
34+
import kotlinx.coroutines.flow.Flow
3235
import kotlinx.coroutines.flow.MutableStateFlow
3336
import kotlinx.coroutines.flow.SharingStarted
3437
import kotlinx.coroutines.flow.StateFlow
3538
import kotlinx.coroutines.flow.combine
3639
import kotlinx.coroutines.flow.onEach
40+
import kotlinx.coroutines.flow.receiveAsFlow
3741
import kotlinx.coroutines.flow.stateIn
3842
import kotlinx.coroutines.flow.update
3943
import kotlinx.coroutines.launch
@@ -50,6 +54,9 @@ class ChatHistoryViewModel @Inject constructor(
5054

5155
private val controls = MutableStateFlow(UiControls())
5256

57+
private val navigationChannel = Channel<NavigationEvent>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
58+
val navigationEvents: Flow<NavigationEvent> = navigationChannel.receiveAsFlow()
59+
5360
/** Cached snapshot so non-suspend action methods can read Recent without re-subscribing. */
5461
private var latestItems: List<ChatHistoryItem> = emptyList()
5562

@@ -123,6 +130,10 @@ class ChatHistoryViewModel @Inject constructor(
123130
dispatchSelectedClear(setOf(chatId))
124131
}
125132

133+
fun onRenameRequested(chatId: String, currentTitle: String) {
134+
navigationChannel.trySend(NavigationEvent.OpenRename(chatId = chatId, currentTitle = currentTitle))
135+
}
136+
126137
private fun dispatchSelectedClear(chatIds: Set<String>) {
127138
if (chatIds.isEmpty()) return
128139
if (!duckAiFeatureState.showClearDuckAIChatHistory.value) return
@@ -247,6 +258,10 @@ class ChatHistoryViewModel @Inject constructor(
247258
val query: String = "",
248259
)
249260

261+
sealed interface NavigationEvent {
262+
data class OpenRename(val chatId: String, val currentTitle: String) : NavigationEvent
263+
}
264+
250265
private companion object {
251266
const val STOP_TIMEOUT_MILLIS = 5_000L
252267
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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.history
18+
19+
import android.os.Bundle
20+
import android.text.Editable
21+
import android.view.View
22+
import androidx.lifecycle.Lifecycle
23+
import androidx.lifecycle.ViewModelProvider
24+
import androidx.lifecycle.flowWithLifecycle
25+
import androidx.lifecycle.lifecycleScope
26+
import com.duckduckgo.anvil.annotations.InjectWith
27+
import com.duckduckgo.common.ui.DuckDuckGoFragment
28+
import com.duckduckgo.common.ui.viewbinding.viewBinding
29+
import com.duckduckgo.common.utils.FragmentViewModelFactory
30+
import com.duckduckgo.common.utils.extensions.hideKeyboard
31+
import com.duckduckgo.common.utils.text.TextChangedWatcher
32+
import com.duckduckgo.di.scopes.FragmentScope
33+
import com.duckduckgo.duckchat.impl.R
34+
import com.duckduckgo.duckchat.impl.databinding.FragmentRenameChatBinding
35+
import com.google.android.material.snackbar.Snackbar
36+
import kotlinx.coroutines.flow.launchIn
37+
import kotlinx.coroutines.flow.onEach
38+
import javax.inject.Inject
39+
40+
@InjectWith(FragmentScope::class)
41+
class RenameChatFragment : DuckDuckGoFragment(R.layout.fragment_rename_chat) {
42+
43+
@Inject
44+
lateinit var viewModelFactory: FragmentViewModelFactory
45+
46+
private val binding: FragmentRenameChatBinding by viewBinding()
47+
private val viewModel: RenameChatViewModel by lazy {
48+
ViewModelProvider(this, viewModelFactory)[RenameChatViewModel::class.java]
49+
}
50+
51+
private val chatId: String by lazy { checkNotNull(requireArguments().getString(ARG_CHAT_ID)) }
52+
private val initialTitle: String by lazy { requireArguments().getString(ARG_CURRENT_TITLE).orEmpty() }
53+
54+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
55+
super.onViewCreated(view, savedInstanceState)
56+
57+
binding.toolbar.setTitle(R.string.duck_ai_chat_history_rename_title)
58+
binding.toolbar.setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_arrow_left_24)
59+
binding.toolbar.setNavigationOnClickListener { dismiss() }
60+
binding.toolbar.inflateMenu(R.menu.menu_rename_chat)
61+
binding.toolbar.setOnMenuItemClickListener { item ->
62+
if (item.itemId == R.id.action_rename_confirm) {
63+
onConfirmClicked()
64+
true
65+
} else {
66+
false
67+
}
68+
}
69+
70+
binding.titleInput.text = initialTitle
71+
binding.titleInput.addTextChangedListener(titleTextWatcher)
72+
binding.titleInput.showKeyboardDelayed()
73+
setConfirmEnabled(false)
74+
75+
viewModel.results
76+
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
77+
.onEach(::onRenameResult)
78+
.launchIn(viewLifecycleOwner.lifecycleScope)
79+
}
80+
81+
private fun onConfirmClicked() {
82+
viewModel.onSaveClicked(chatId, binding.titleInput.text)
83+
}
84+
85+
private fun onRenameResult(result: RenameChatViewModel.RenameResult) {
86+
when (result) {
87+
RenameChatViewModel.RenameResult.Success -> dismiss()
88+
RenameChatViewModel.RenameResult.Error ->
89+
Snackbar.make(binding.root, R.string.duck_ai_chat_history_rename_error, Snackbar.LENGTH_SHORT).show()
90+
}
91+
}
92+
93+
private fun dismiss() {
94+
requireActivity().hideKeyboard()
95+
parentFragmentManager.popBackStack()
96+
}
97+
98+
private fun setConfirmEnabled(enabled: Boolean) {
99+
binding.toolbar.menu.findItem(R.id.action_rename_confirm)?.isEnabled = enabled
100+
}
101+
102+
private val titleTextWatcher = object : TextChangedWatcher() {
103+
override fun afterTextChanged(editable: Editable) {
104+
val candidate = editable.toString().trim()
105+
setConfirmEnabled(candidate.isNotBlank() && candidate != initialTitle.trim())
106+
}
107+
}
108+
109+
companion object {
110+
private const val ARG_CHAT_ID = "arg_chat_id"
111+
private const val ARG_CURRENT_TITLE = "arg_current_title"
112+
113+
fun newInstance(chatId: String, currentTitle: String): RenameChatFragment = RenameChatFragment().apply {
114+
arguments = Bundle().apply {
115+
putString(ARG_CHAT_ID, chatId)
116+
putString(ARG_CURRENT_TITLE, currentTitle)
117+
}
118+
}
119+
}
120+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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.history
18+
19+
import androidx.lifecycle.ViewModel
20+
import com.duckduckgo.anvil.annotations.ContributesViewModel
21+
import com.duckduckgo.app.di.AppCoroutineScope
22+
import com.duckduckgo.di.scopes.FragmentScope
23+
import kotlinx.coroutines.CancellationException
24+
import kotlinx.coroutines.CoroutineScope
25+
import kotlinx.coroutines.channels.BufferOverflow
26+
import kotlinx.coroutines.channels.Channel
27+
import kotlinx.coroutines.flow.Flow
28+
import kotlinx.coroutines.flow.receiveAsFlow
29+
import kotlinx.coroutines.launch
30+
import javax.inject.Inject
31+
32+
@ContributesViewModel(FragmentScope::class)
33+
class RenameChatViewModel @Inject constructor(
34+
private val chatHistoryRepository: ChatHistoryRepository,
35+
@AppCoroutineScope private val appScope: CoroutineScope,
36+
) : ViewModel() {
37+
38+
private val resultChannel = Channel<RenameResult>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
39+
val results: Flow<RenameResult> = resultChannel.receiveAsFlow()
40+
41+
fun onSaveClicked(chatId: String, newTitle: String) {
42+
appScope.launch {
43+
val outcome = try {
44+
if (chatHistoryRepository.renameChat(chatId, newTitle.trim())) RenameResult.Success else RenameResult.Error
45+
} catch (e: CancellationException) {
46+
throw e
47+
} catch (e: Exception) {
48+
RenameResult.Error
49+
}
50+
resultChannel.trySend(outcome)
51+
}
52+
}
53+
54+
sealed interface RenameResult {
55+
data object Success : RenameResult
56+
data object Error : RenameResult
57+
}
58+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
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+
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
18+
xmlns:app="http://schemas.android.com/apk/res-auto"
19+
xmlns:tools="http://schemas.android.com/tools"
20+
android:layout_width="match_parent"
21+
android:layout_height="match_parent"
22+
tools:context="com.duckduckgo.duckchat.impl.history.RenameChatFragment">
23+
24+
<com.google.android.material.appbar.AppBarLayout
25+
android:id="@+id/appBarLayout"
26+
android:layout_width="match_parent"
27+
android:layout_height="wrap_content"
28+
android:theme="@style/Widget.DuckDuckGo.ToolbarTheme"
29+
app:layout_constraintStart_toStartOf="parent"
30+
app:layout_constraintTop_toTopOf="parent">
31+
32+
<androidx.appcompat.widget.Toolbar
33+
android:id="@+id/toolbar"
34+
android:layout_width="match_parent"
35+
android:layout_height="?attr/actionBarSize"
36+
android:background="?attr/daxColorToolbar"
37+
android:theme="@style/Widget.DuckDuckGo.ToolbarTheme"
38+
app:popupTheme="@style/Widget.DuckDuckGo.PopUpOverflowMenu" />
39+
</com.google.android.material.appbar.AppBarLayout>
40+
41+
<com.duckduckgo.common.ui.view.text.DaxTextInput
42+
android:id="@+id/titleInput"
43+
android:layout_width="match_parent"
44+
android:layout_height="wrap_content"
45+
android:layout_margin="@dimen/keyline_4"
46+
android:hint="@string/duck_ai_chat_history_rename_chat_title_hint"
47+
app:capitalizeKeyboard="true"
48+
app:layout_constraintEnd_toEndOf="parent"
49+
app:layout_constraintStart_toStartOf="parent"
50+
app:layout_constraintTop_toBottomOf="@id/appBarLayout"
51+
app:type="single_line" />
52+
53+
</androidx.constraintlayout.widget.ConstraintLayout>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
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+
<menu xmlns:android="http://schemas.android.com/apk/res/android"
18+
xmlns:app="http://schemas.android.com/apk/res-auto">
19+
<item
20+
android:id="@+id/action_rename_confirm"
21+
android:contentDescription="@string/duck_ai_chat_history_rename_confirm_content_description"
22+
android:enabled="false"
23+
android:icon="@drawable/ic_check_24"
24+
android:title="@string/duck_ai_chat_history_rename_confirm_title"
25+
app:showAsAction="always" />
26+
</menu>

duckchat/duckchat-impl/src/main/res/values/donottranslate.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,9 @@
102102
<string name="duck_ai_chat_history_row_selected_content_description">Selected</string>
103103
<string name="duck_ai_chat_history_row_unselected_content_description">Not selected</string>
104104
<string name="duck_ai_chat_history_exit_select_mode_content_description">Exit selection mode</string>
105+
<string name="duck_ai_chat_history_rename_title">Rename Chat</string>
106+
<string name="duck_ai_chat_history_rename_chat_title_hint">Chat Title</string>
107+
<string name="duck_ai_chat_history_rename_confirm_title">Save</string>
108+
<string name="duck_ai_chat_history_rename_confirm_content_description">Save chat title</string>
109+
<string name="duck_ai_chat_history_rename_error">Couldn\'t rename chat</string>
105110
</resources>

0 commit comments

Comments
 (0)