diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarkCountsStore.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarkCountsStore.kt new file mode 100644 index 000000000..70333fd91 --- /dev/null +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarkCountsStore.kt @@ -0,0 +1,31 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.ui.bookmarks + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class BookmarkCountsStore { + private val mutableCounts = MutableStateFlow>(emptyMap()) + + val counts: StateFlow> = mutableCounts.asStateFlow() + + fun setCounts(counts: Map) { + mutableCounts.value = counts.toMap() + } + + fun applyBookmarkChange(sessionId: String, isBookmarked: Boolean) { + mutableCounts.update { current -> + val nextCount = (current[sessionId] ?: 0) + if (isBookmarked) 1 else -1 + if (nextCount > 0) { + current + (sessionId to nextCount) + } else { + current - sessionId + } + } + } +} diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksViewModel.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksViewModel.kt index fdd079650..398496e71 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksViewModel.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksViewModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext class BookmarksViewModel( private val chatSessionService: ChatSessionService, private val scope: CoroutineScope, + private val bookmarkCountsStore: BookmarkCountsStore, ) { private val log = logger() @@ -63,6 +64,10 @@ class BookmarksViewModel( * then persist the change via [chatSessionService]. */ fun removeBookmark(messageId: String) { + val sessionId = groups.firstOrNull { group -> + group.messages.any { it.id == messageId } + }?.session?.id + // Optimistic update groups = groups.mapNotNull { group -> val updated = group.messages.filter { it.id != messageId } @@ -71,9 +76,12 @@ class BookmarksViewModel( scope.launch { try { - withContext(Dispatchers.IO) { + val isBookmarked = withContext(Dispatchers.IO) { chatSessionService.toggleBookmark(messageId) } + if (sessionId != null) { + bookmarkCountsStore.applyBookmarkChange(sessionId, isBookmarked) + } } catch (e: Exception) { log.error("Failed to remove bookmark for message {}", messageId, e) load() diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatViewModel.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatViewModel.kt index 7f2086d5a..7948f081e 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatViewModel.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatViewModel.kt @@ -25,6 +25,7 @@ import io.askimo.core.event.internal.DiagramFixedEvent import io.askimo.core.event.internal.ProjectRefreshEvent import io.askimo.core.event.internal.SessionTitleUpdatedEvent import io.askimo.core.logging.logger +import io.askimo.ui.bookmarks.BookmarkCountsStore import io.askimo.ui.session.SessionManager import io.askimo.ui.util.ErrorHandler import kotlinx.coroutines.CoroutineScope @@ -52,6 +53,7 @@ class ChatViewModel( private val sessionManager: SessionManager, private val scope: CoroutineScope, private val chatSessionService: ChatSessionService, + private val bookmarkCountsStore: BookmarkCountsStore, ) : ChatActions { private val log = logger() @@ -1358,6 +1360,7 @@ class ChatViewModel( * for instant feedback (optimistic update). */ override fun toggleBookmark(messageId: String) { + val sessionId = currentSessionId.value bookmarkedMessageIds = if (messageId in bookmarkedMessageIds) { bookmarkedMessageIds - messageId } else { @@ -1366,9 +1369,12 @@ class ChatViewModel( scope.launch { try { - withContext(Dispatchers.IO) { + val isBookmarked = withContext(Dispatchers.IO) { chatSessionService.toggleBookmark(messageId) } + if (sessionId != null) { + bookmarkCountsStore.applyBookmarkChange(sessionId, isBookmarked) + } } catch (e: Exception) { // Roll back the optimistic update on failure bookmarkedMessageIds = if (messageId in bookmarkedMessageIds) { diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt index 89376b1da..7fdd167e8 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt @@ -24,6 +24,7 @@ import io.askimo.core.providers.ConfigurationErrorException import io.askimo.core.providers.isContextLengthError import io.askimo.core.providers.sendStreamingMessageWithCallback import io.askimo.core.vision.ImageProcessor +import io.askimo.ui.bookmarks.BookmarkCountsStore import io.askimo.ui.chat.ChatViewModel import io.askimo.ui.chat.CreationMode import kotlinx.coroutines.CoroutineScope @@ -62,6 +63,7 @@ import java.util.concurrent.ConcurrentHashMap class SessionManager( private val chatSessionService: ChatSessionService, private val scope: CoroutineScope, + private val bookmarkCountsStore: BookmarkCountsStore, ) { private val log = logger() @@ -476,6 +478,7 @@ class SessionManager( sessionManager = this, scope = scope, chatSessionService = chatSessionService, + bookmarkCountsStore = bookmarkCountsStore, ) chatViewModels[sessionId] = viewModel diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionsViewModel.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionsViewModel.kt index d27f8dc2c..d46b5614a 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionsViewModel.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionsViewModel.kt @@ -17,6 +17,7 @@ import io.askimo.core.event.internal.SessionTitleUpdatedEvent import io.askimo.core.event.internal.SessionsRefreshEvent import io.askimo.core.i18n.LocalizationManager import io.askimo.core.logging.logger +import io.askimo.ui.bookmarks.BookmarkCountsStore import io.askimo.ui.common.export.ExportFormat import io.askimo.ui.shell.PinnedSidebarState import io.askimo.ui.util.ErrorHandler @@ -40,6 +41,7 @@ class SessionsViewModel( private val scope: CoroutineScope, private val sessionService: ChatSessionService, private val sessionManager: SessionManager, + private val bookmarkCountsStore: BookmarkCountsStore, private val onCreateNewSession: () -> String, private val onRenameComplete: () -> Unit = {}, // Callback when rename completes ) : PinnedSidebarState { @@ -74,7 +76,7 @@ class SessionsViewModel( private set /** sessionId → number of bookmarked messages. Only populated for sessions that have ≥1 bookmark. */ - var bookmarkCountsBySession by mutableStateOf>(emptyMap()) + var bookmarkCountsBySession by mutableStateOf(bookmarkCountsStore.counts.value) private set var searchQuery by mutableStateOf("") @@ -126,12 +128,21 @@ class SessionsViewModel( } init { + subscribeToBookmarkCounts() loadSessions(1) loadRecentSessions() loadStarredSessions() subscribeToSessionEvents() } + private fun subscribeToBookmarkCounts() { + scope.launch { + bookmarkCountsStore.counts.collect { counts -> + bookmarkCountsBySession = counts + } + } + } + /** * Subscribe to internal events to keep session list updated. */ @@ -200,7 +211,7 @@ class SessionsViewModel( } recentSessions = sessions totalSessionCount = total - bookmarkCountsBySession = bookmarkCounts + bookmarkCountsStore.setCounts(bookmarkCounts) log.debug("Loaded ${sessions.size} sessions without projects (showing ${sessions.size} in sidebar, total: $total)") } catch (e: Exception) { diff --git a/desktop-shared/src/test/kotlin/io/askimo/ui/bookmarks/BookmarkCountsStoreTest.kt b/desktop-shared/src/test/kotlin/io/askimo/ui/bookmarks/BookmarkCountsStoreTest.kt new file mode 100644 index 000000000..398568955 --- /dev/null +++ b/desktop-shared/src/test/kotlin/io/askimo/ui/bookmarks/BookmarkCountsStoreTest.kt @@ -0,0 +1,70 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.ui.bookmarks + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BookmarkCountsStoreTest { + @Test + fun `setCounts publishes a snapshot`() { + val source = mutableMapOf("session-a" to 2) + val store = BookmarkCountsStore() + + store.setCounts(source) + source["session-a"] = 3 + + assertEquals(mapOf("session-a" to 2), store.counts.value) + } + + @Test + fun `bookmark creates a count for a session`() { + val store = BookmarkCountsStore() + + store.applyBookmarkChange("session-a", isBookmarked = true) + + assertEquals(mapOf("session-a" to 1), store.counts.value) + } + + @Test + fun `bookmark increments the session without changing other counts`() { + val store = BookmarkCountsStore() + store.setCounts(mapOf("session-a" to 2, "session-b" to 4)) + + store.applyBookmarkChange("session-a", isBookmarked = true) + + assertEquals(mapOf("session-a" to 3, "session-b" to 4), store.counts.value) + } + + @Test + fun `unbookmark decrements the session count`() { + val store = BookmarkCountsStore() + store.setCounts(mapOf("session-a" to 2)) + + store.applyBookmarkChange("session-a", isBookmarked = false) + + assertEquals(mapOf("session-a" to 1), store.counts.value) + } + + @Test + fun `unbookmark removes a session after its final bookmark`() { + val store = BookmarkCountsStore() + store.setCounts(mapOf("session-a" to 1, "session-b" to 2)) + + store.applyBookmarkChange("session-a", isBookmarked = false) + + assertEquals(mapOf("session-b" to 2), store.counts.value) + } + + @Test + fun `unbookmark leaves counts unchanged for a missing session`() { + val store = BookmarkCountsStore() + store.setCounts(mapOf("session-a" to 2)) + + store.applyBookmarkChange("session-b", isBookmarked = false) + + assertEquals(mapOf("session-a" to 2), store.counts.value) + } +} diff --git a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt index 2f89200bf..ab984c353 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt @@ -98,6 +98,7 @@ import io.askimo.desktop.shell.footerBar import io.askimo.desktop.shell.navigationSidebar import io.askimo.desktop.shell.telemetryPanel import io.askimo.desktop.user.userProfileDialog +import io.askimo.ui.bookmarks.BookmarkCountsStore import io.askimo.ui.bookmarks.BookmarksViewModel import io.askimo.ui.bookmarks.bookmarksView import io.askimo.ui.chat.ChatViewModel @@ -475,8 +476,9 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = val appContext = remember { koin.get() } val chatSessionService = remember { koin.get() } + val bookmarkCountsStore = remember { koin.get() } - val bookmarksViewModel = remember { BookmarksViewModel(chatSessionService, scope) } + val bookmarksViewModel = remember { BookmarksViewModel(chatSessionService, scope, bookmarkCountsStore) } val sessionManager = remember { koin.get() } val sessionsViewModel = remember { diff --git a/desktop/src/main/kotlin/io/askimo/desktop/di/DesktopModule.kt b/desktop/src/main/kotlin/io/askimo/desktop/di/DesktopModule.kt index 061cc51ba..85d00973a 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/di/DesktopModule.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/di/DesktopModule.kt @@ -19,6 +19,7 @@ import io.askimo.core.tools.ToolProviderImpl import io.askimo.desktop.project.ProjectViewModel import io.askimo.desktop.project.ProjectsViewModel import io.askimo.desktop.settings.AIProviderViewModel +import io.askimo.ui.bookmarks.BookmarkCountsStore import io.askimo.ui.chat.ProjectIndexStateManager import io.askimo.ui.common.monitoring.SystemResourceMonitor import io.askimo.ui.discover.DiscoverViewModel @@ -89,11 +90,13 @@ val desktopModule = module { single { SystemResourceMonitor() } single { ProjectIndexStateManager() } + single { BookmarkCountsStore() } single { SessionManager( chatSessionService = get(), scope = CoroutineScope(Dispatchers.Default + SupervisorJob()), + bookmarkCountsStore = get(), ) } @@ -102,6 +105,7 @@ val desktopModule = module { scope = scope, sessionService = get(), sessionManager = sessionManager, + bookmarkCountsStore = get(), onCreateNewSession = onCreateNewSession, onRenameComplete = onRenameComplete, )