Skip to content
Open
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
@@ -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<Map<String, Int>>(emptyMap())

val counts: StateFlow<Map<String, Int>> = mutableCounts.asStateFlow()

fun setCounts(counts: Map<String, Int>) {
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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<BookmarksViewModel>()

Expand Down Expand Up @@ -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 }
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ChatViewModel>()

Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SessionManager>()

Expand Down Expand Up @@ -476,6 +478,7 @@ class SessionManager(
sessionManager = this,
scope = scope,
chatSessionService = chatSessionService,
bookmarkCountsStore = bookmarkCountsStore,
)

chatViewModels[sessionId] = viewModel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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<Map<String, Int>>(emptyMap())
var bookmarkCountsBySession by mutableStateOf(bookmarkCountsStore.counts.value)
private set

var searchQuery by mutableStateOf("")
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 3 additions & 1 deletion desktop/src/main/kotlin/io/askimo/desktop/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -475,8 +476,9 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? =

val appContext = remember { koin.get<AppContext>() }
val chatSessionService = remember { koin.get<ChatSessionService>() }
val bookmarkCountsStore = remember { koin.get<BookmarkCountsStore>() }

val bookmarksViewModel = remember { BookmarksViewModel(chatSessionService, scope) }
val bookmarksViewModel = remember { BookmarksViewModel(chatSessionService, scope, bookmarkCountsStore) }

val sessionManager = remember { koin.get<SessionManager>() }
val sessionsViewModel = remember {
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/main/kotlin/io/askimo/desktop/di/DesktopModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,11 +90,13 @@ val desktopModule = module {
single { SystemResourceMonitor() }

single { ProjectIndexStateManager() }
single { BookmarkCountsStore() }

single {
SessionManager(
chatSessionService = get(),
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
bookmarkCountsStore = get(),
)
}

Expand All @@ -102,6 +105,7 @@ val desktopModule = module {
scope = scope,
sessionService = get(),
sessionManager = sessionManager,
bookmarkCountsStore = get(),
onCreateNewSession = onCreateNewSession,
onRenameComplete = onRenameComplete,
)
Expand Down