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 @@ -29,6 +29,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.common.ui.DuckDuckGoFragment
import com.duckduckgo.common.ui.menu.PopupMenu
import com.duckduckgo.common.ui.view.PopupMenuItemView
import com.duckduckgo.common.ui.view.SearchBar
import com.duckduckgo.common.ui.view.gone
import com.duckduckgo.common.ui.view.show
Expand Down Expand Up @@ -124,6 +125,11 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.onEach(::onNavigationEvent)
.launchIn(viewLifecycleOwner.lifecycleScope)

viewModel.messageEvents
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.onEach(::onMessageEvent)
.launchIn(viewLifecycleOwner.lifecycleScope)
}

private fun onNavigationEvent(event: ChatHistoryViewModel.NavigationEvent) {
Expand All @@ -132,13 +138,32 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
}
}

private fun onMessageEvent(event: ChatHistoryViewModel.MessageEvent) {
when (event) {
is ChatHistoryViewModel.MessageEvent.PinToggled -> showPinToggledSnackbar(event)
}
}

private fun openRenameScreen(chatId: String, currentTitle: String) {
parentFragmentManager.beginTransaction()
.replace(R.id.chatHistoryFragmentContainer, RenameChatFragment.newInstance(chatId, currentTitle))
.addToBackStack(null)
.commit()
}

private fun showPinToggledSnackbar(event: ChatHistoryViewModel.MessageEvent.PinToggled) {
val messageRes = if (event.wasPinned) {
R.string.duck_ai_chat_history_unpin_snackbar
} else {
R.string.duck_ai_chat_history_pin_snackbar
}
Snackbar.make(binding.root, messageRes, Snackbar.LENGTH_LONG)
.setAction(R.string.duck_ai_chat_history_undo) {
viewModel.onUndoTogglePin(event.chatId, restorePinned = event.wasPinned)
}
.show()
}

private fun render(state: ChatHistoryUiState) {
logcat { "ChatHistory: render ${state::class.simpleName}" }
when (state) {
Expand Down Expand Up @@ -286,7 +311,10 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
private fun showRowPopup(item: ChatHistoryItem, anchor: View) {
val popup = PopupMenu(layoutInflater, R.layout.popup_chat_history_row)
val view = popup.contentView
popup.onMenuItemClicked(view.findViewById(R.id.pin)) { showComingSoonSnackbar() }
val pinAction = view.findViewById<PopupMenuItemView>(R.id.pin)
val pinLabel = if (item.pinned) R.string.duck_ai_chat_history_action_unpin else R.string.duck_ai_chat_history_action_pin
pinAction.setPrimaryText(getString(pinLabel))
popup.onMenuItemClicked(pinAction) { viewModel.onTogglePin(item.chatId) }
popup.onMenuItemClicked(view.findViewById(R.id.rename)) {
viewModel.onRenameRequested(item.chatId, item.displayTitle)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface ChatHistoryRepository {
suspend fun deleteChat(chatId: String)
suspend fun deleteAllChats()
suspend fun renameChat(chatId: String, newTitle: String): Boolean
suspend fun setPinned(chatId: String, pinned: Boolean)
}

@ContributesBinding(AppScope::class)
Expand Down Expand Up @@ -63,6 +64,12 @@ class RealChatHistoryRepository @Inject constructor(
override suspend fun renameChat(chatId: String, newTitle: String): Boolean =
withContext(dispatchers.io()) { chatStore.renameChat(chatId, newTitle) }

override suspend fun setPinned(chatId: String, pinned: Boolean) {
withContext(dispatchers.io()) {
if (pinned) chatStore.pinChat(chatId) else chatStore.unpinChat(chatId)
}
}

private fun toChatHistoryItem(chat: DuckAiChat): ChatHistoryItem = ChatHistoryItem(
chatId = chat.chatId,
displayTitle = chat.title.takeIf { it.isNotBlank() && it != UPSTREAM_UNTITLED } ?: fallbackTitle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ class ChatHistoryViewModel @Inject constructor(
private val navigationChannel = Channel<NavigationEvent>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val navigationEvents: Flow<NavigationEvent> = navigationChannel.receiveAsFlow()

private val messageChannel = Channel<MessageEvent>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val messageEvents: Flow<MessageEvent> = messageChannel.receiveAsFlow()

/** Cached snapshot so non-suspend action methods can read Recent without re-subscribing. */
private var latestItems: List<ChatHistoryItem> = emptyList()

Expand Down Expand Up @@ -134,6 +137,17 @@ class ChatHistoryViewModel @Inject constructor(
navigationChannel.trySend(NavigationEvent.OpenRename(chatId = chatId, currentTitle = currentTitle))
}

fun onTogglePin(chatId: String) {
val current = latestItems.firstOrNull { it.chatId == chatId } ?: return
val wasPinned = current.pinned
appScope.launch { chatHistoryRepository.setPinned(chatId, !wasPinned) }
messageChannel.trySend(MessageEvent.PinToggled(chatId = chatId, wasPinned = wasPinned))
}

fun onUndoTogglePin(chatId: String, restorePinned: Boolean) {
appScope.launch { chatHistoryRepository.setPinned(chatId, restorePinned) }
}

private fun dispatchSelectedClear(chatIds: Set<String>) {
if (chatIds.isEmpty()) return
if (!duckAiFeatureState.showClearDuckAIChatHistory.value) return
Expand Down Expand Up @@ -262,6 +276,10 @@ class ChatHistoryViewModel @Inject constructor(
data class OpenRename(val chatId: String, val currentTitle: String) : NavigationEvent
}

sealed interface MessageEvent {
data class PinToggled(val chatId: String, val wasPinned: Boolean) : MessageEvent
}

private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
}
Expand Down
3 changes: 3 additions & 0 deletions duckchat/duckchat-impl/src/main/res/values/donottranslate.xml
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,7 @@
<string name="duck_ai_chat_history_rename_confirm_title">Save</string>
<string name="duck_ai_chat_history_rename_confirm_content_description">Save chat title</string>
<string name="duck_ai_chat_history_rename_error">Couldn\'t rename chat</string>
<string name="duck_ai_chat_history_pin_snackbar">Chat pinned</string>
<string name="duck_ai_chat_history_unpin_snackbar">Chat unpinned</string>
<string name="duck_ai_chat_history_undo">Undo</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,106 @@ class ChatHistoryViewModelTest {
}
}

@Test
fun `onTogglePin flips pinned to true when row was unpinned`() = coroutineRule.testScope.runTest {
source.value = listOf(item("a", pinned = false))

viewModel.uiState.test {
awaitInitialLoaded()
viewModel.onTogglePin("a")
val updated = awaitItem() as Loaded
assertEquals(listOf("a"), updated.pinned.map { it.chatId })
assertEquals(emptyList<String>(), updated.recent.map { it.chatId })
}
assertEquals(listOf("a" to true), repository.pinnedChats)
}

@Test
fun `onTogglePin flips pinned to false when row was pinned`() = coroutineRule.testScope.runTest {
source.value = listOf(item("a", pinned = true))

viewModel.uiState.test {
awaitInitialLoaded()
viewModel.onTogglePin("a")
val updated = awaitItem() as Loaded
assertEquals(emptyList<String>(), updated.pinned.map { it.chatId })
assertEquals(listOf("a"), updated.recent.map { it.chatId })
}
assertEquals(listOf("a" to false), repository.pinnedChats)
}

@Test
fun `onTogglePin is a no-op when chatId is unknown`() = coroutineRule.testScope.runTest {
source.value = listOf(item("a", pinned = false))

viewModel.uiState.test {
awaitInitialLoaded()
viewModel.onTogglePin("missing")
expectNoEvents()
}
assertTrue(repository.pinnedChats.isEmpty())
}

@Test
fun `onTogglePin emits PinToggled message event with previous pinned state false`() = coroutineRule.testScope.runTest {
source.value = listOf(item("a", pinned = false))

viewModel.messageEvents.test {
// Drain the initial Loaded state so latestItems is primed.
viewModel.uiState.test { awaitInitialLoaded() }
viewModel.onTogglePin("a")

val event = awaitItem()
assertTrue(event is ChatHistoryViewModel.MessageEvent.PinToggled)
event as ChatHistoryViewModel.MessageEvent.PinToggled
assertEquals("a", event.chatId)
assertEquals(false, event.wasPinned)
}
}

@Test
fun `onTogglePin emits PinToggled message event with previous pinned state true`() = coroutineRule.testScope.runTest {
source.value = listOf(item("a", pinned = true))

viewModel.messageEvents.test {
viewModel.uiState.test { awaitInitialLoaded() }
viewModel.onTogglePin("a")

val event = awaitItem()
assertTrue(event is ChatHistoryViewModel.MessageEvent.PinToggled)
event as ChatHistoryViewModel.MessageEvent.PinToggled
assertEquals("a", event.chatId)
assertEquals(true, event.wasPinned)
}
}

@Test
fun `onTogglePin emits no message event when chatId is unknown`() = coroutineRule.testScope.runTest {
source.value = listOf(item("a", pinned = false))

viewModel.messageEvents.test {
viewModel.uiState.test { awaitInitialLoaded() }
viewModel.onTogglePin("missing")
expectNoEvents()
}
}

@Test
fun `onUndoTogglePin restores the requested pinned state via the repository`() = coroutineRule.testScope.runTest {
source.value = listOf(item("a", pinned = true))

viewModel.uiState.test {
awaitInitialLoaded()
viewModel.onTogglePin("a") // a → unpinned
awaitItem() // Loaded after the unpin write
viewModel.onUndoTogglePin("a", restorePinned = true)
val restored = awaitItem() as Loaded
assertEquals(listOf("a"), restored.pinned.map { it.chatId })
assertEquals(emptyList<String>(), restored.recent.map { it.chatId })
}
assertEquals(listOf("a" to false, "a" to true), repository.pinnedChats)
}

/**
* `stateIn(WhileSubscribed)` does not guarantee subscribers observe the `Loading` initial
* value — the upstream may emit before the StateFlow can replay it. Tolerate both orderings.
Expand Down Expand Up @@ -851,6 +951,7 @@ private class FakeChatHistoryRepository(
) : ChatHistoryRepository {
val deletedChatIds: MutableList<String> = mutableListOf()
val renamedChats: MutableList<Pair<String, String>> = mutableListOf()
val pinnedChats: MutableList<Pair<String, Boolean>> = mutableListOf()
var deleteAllChatsCalled: Boolean = false
private set

Expand All @@ -870,6 +971,11 @@ private class FakeChatHistoryRepository(
renamedChats += chatId to newTitle
return true
}

override suspend fun setPinned(chatId: String, pinned: Boolean) {
pinnedChats += chatId to pinned
source.value = source.value.map { if (it.chatId == chatId) it.copy(pinned = pinned) else it }
}
}

private class RecordingDataClearingTrigger : DataClearingTrigger {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,6 @@ private class RecordingRenameRepository : ChatHistoryRepository {
renames += chatId to newTitle
return nextResult
}

override suspend fun setPinned(chatId: String, pinned: Boolean) = Unit
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ interface DuckAiChatStore {

/** Renames [chatId] in the FE-owned JSON blob. Returns true if the chat existed and was updated, false otherwise. */
suspend fun renameChat(chatId: String, newTitle: String): Boolean

/** Pins [chatId]. Silent no-op if the chat is not found or the stored JSON is malformed. */
suspend fun pinChat(chatId: String)

/** Unpins [chatId]. Silent no-op if the chat is not found or the stored JSON is malformed. */
suspend fun unpinChat(chatId: String)
}

@SingleInstanceIn(AppScope::class)
Expand Down Expand Up @@ -143,6 +149,21 @@ class RealDuckAiChatStore @Inject constructor(
true
}

override suspend fun pinChat(chatId: String) = setPinned(chatId, pinned = true)

override suspend fun unpinChat(chatId: String) = setPinned(chatId, pinned = false)

private suspend fun setPinned(chatId: String, pinned: Boolean) {
withContext(dispatchers.io()) {
logcat { "DuckAI: RealDuckAiChatStore.setPinned($chatId, $pinned)" }
val entity = chatsDao.getById(chatId) ?: return@withContext
val updatedJson = runCatching {
JSONObject(entity.data).put("pinned", pinned).toString()
}.getOrNull() ?: return@withContext
chatsDao.upsert(entity.copy(data = updatedJson))
}
}

private fun DuckAiBridgeChatEntity.toDuckAiChat(): DuckAiChat? = runCatching {
val json = JSONObject(data)
val chatId = json.optString("chatId").takeIf { it.isNotEmpty() } ?: return@runCatching null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,4 +336,60 @@ class RealDuckAiChatStoreTest {
// traversal file should not be deleted — it's outside filesDir
verify(chatsDao).deleteAll()
}

// --- pinChat / unpinChat ---

@Test
fun `pinChat is a no-op when chat not found`() = runTest {
whenever(chatsDao.getById("missing")).thenReturn(null)

store.pinChat("missing")

verify(chatsDao, never()).upsert(any())
}

@Test
fun `pinChat sets pinned to true and preserves other JSON fields`() = runTest {
val originalJson = """
{"chatId":"abc","title":"Old","model":"gpt-5-mini","lastEdit":"2026-04-01T21:31:54.260Z","pinned":false,"fileRefs":["uuid1"],"messages":[{"role":"user","text":"hi"}]}
""".trimIndent()
whenever(chatsDao.getById("abc")).thenReturn(DuckAiBridgeChatEntity("abc", originalJson))

store.pinChat("abc")

val entityCaptor = argumentCaptor<DuckAiBridgeChatEntity>()
verify(chatsDao).upsert(entityCaptor.capture())
val json = JSONObject(entityCaptor.firstValue.data)
assertTrue(json.getBoolean("pinned"))
assertEquals("Old", json.getString("title"))
assertEquals("abc", json.getString("chatId"))
assertEquals("gpt-5-mini", json.getString("model"))
assertEquals("2026-04-01T21:31:54.260Z", json.getString("lastEdit"))
assertEquals("uuid1", json.getJSONArray("fileRefs").getString(0))
assertEquals("hi", json.getJSONArray("messages").getJSONObject(0).getString("text"))
}

@Test
fun `unpinChat sets pinned to false`() = runTest {
val originalJson = """
{"chatId":"abc","title":"Old","model":"gpt-5-mini","lastEdit":"2026-04-01T21:31:54.260Z","pinned":true}
""".trimIndent()
whenever(chatsDao.getById("abc")).thenReturn(DuckAiBridgeChatEntity("abc", originalJson))

store.unpinChat("abc")

val entityCaptor = argumentCaptor<DuckAiBridgeChatEntity>()
verify(chatsDao).upsert(entityCaptor.capture())
val json = JSONObject(entityCaptor.firstValue.data)
assertFalse(json.getBoolean("pinned"))
}

@Test
fun `pinChat is a no-op when stored JSON is malformed`() = runTest {
whenever(chatsDao.getById("abc")).thenReturn(DuckAiBridgeChatEntity("abc", "not a json"))

store.pinChat("abc")

verify(chatsDao, never()).upsert(any())
}
}
Loading