diff --git a/cli/src/test/kotlin/io/askimo/core/chat/repository/ChatMessageRepositoryIT.kt b/cli/src/test/kotlin/io/askimo/core/chat/repository/ChatMessageRepositoryIT.kt index 4c11012a2..5f63e102b 100644 --- a/cli/src/test/kotlin/io/askimo/core/chat/repository/ChatMessageRepositoryIT.kt +++ b/cli/src/test/kotlin/io/askimo/core/chat/repository/ChatMessageRepositoryIT.kt @@ -13,6 +13,7 @@ import io.askimo.core.util.AskimoHome import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll @@ -761,4 +762,381 @@ class ChatMessageRepositoryIT { val expectedFileNames = (1..10).map { "file$it.txt" }.toSet() assertEquals(expectedFileNames, fileNames) } + + // ===== Bookmark Tests ===== + + @Test + fun `toggleBookmark should return true when bookmarking a message`() { + val msg = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Bookmark me"), + ) + + val result = messageRepository.toggleBookmark(msg.id) + + assertTrue(result) + } + + @Test + fun `toggleBookmark should return false when un-bookmarking a message`() { + val msg = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Toggle off"), + ) + messageRepository.toggleBookmark(msg.id) // on + + val result = messageRepository.toggleBookmark(msg.id) // off + + assertFalse(result) + } + + @Test + fun `toggleBookmark should persist isBookmarked flag correctly`() { + val msg = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Check flag"), + ) + + messageRepository.toggleBookmark(msg.id) + assertTrue(messageRepository.getMessages(testSession.id).first { it.id == msg.id }.isBookmarked) + + messageRepository.toggleBookmark(msg.id) + assertFalse(messageRepository.getMessages(testSession.id).first { it.id == msg.id }.isBookmarked) + } + + @Test + fun `toggleBookmark should only affect the targeted message`() { + val baseTime = Instant.now() + val target = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Target", createdAt = baseTime), + ) + messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.ASSISTANT, content = "Bystander", createdAt = baseTime.plusSeconds(1)), + ) + + messageRepository.toggleBookmark(target.id) + + val messages = messageRepository.getMessages(testSession.id) + assertTrue(messages.first { it.id == target.id }.isBookmarked) + assertFalse(messages.first { it.content == "Bystander" }.isBookmarked) + } + + // ===== getBookmarkedMessages ===== + + @Test + fun `getBookmarkedMessages should return empty list when no bookmarks in session`() { + messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Not bookmarked"), + ) + + val result = messageRepository.getBookmarkedMessages(testSession.id) + + assertTrue(result.isEmpty()) + } + + @Test + fun `getBookmarkedMessages should return only bookmarked messages for the session`() { + val baseTime = Instant.now() + val bookmarked = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Bookmarked", createdAt = baseTime), + ) + messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.ASSISTANT, content = "Not bookmarked", createdAt = baseTime.plusSeconds(1)), + ) + messageRepository.toggleBookmark(bookmarked.id) + + val result = messageRepository.getBookmarkedMessages(testSession.id) + + assertEquals(1, result.size) + assertEquals(bookmarked.id, result[0].id) + assertEquals("Bookmarked", result[0].content) + } + + @Test + fun `getBookmarkedMessages should return messages ordered by createdAt ascending`() { + val baseTime = Instant.now() + val first = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "First", createdAt = baseTime), + ) + val third = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.ASSISTANT, content = "Third", createdAt = baseTime.plusSeconds(2)), + ) + val second = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Second", createdAt = baseTime.plusSeconds(1)), + ) + messageRepository.toggleBookmark(first.id) + messageRepository.toggleBookmark(second.id) + messageRepository.toggleBookmark(third.id) + + val result = messageRepository.getBookmarkedMessages(testSession.id) + + assertEquals(listOf("First", "Second", "Third"), result.map { it.content }) + } + + @Test + fun `getBookmarkedMessages should not return bookmarks from other sessions`() { + val otherSession = sessionRepository.createSession(ChatSession(id = "", title = "Other Session")) + try { + val otherMsg = messageRepository.addMessage( + ChatMessage(id = "", sessionId = otherSession.id, role = MessageRole.USER, content = "Other session bookmark"), + ) + messageRepository.toggleBookmark(otherMsg.id) + + val result = messageRepository.getBookmarkedMessages(testSession.id) + + assertTrue(result.isEmpty()) + } finally { + sessionRepository.deleteSession(otherSession.id) + } + } + + // ===== getAllBookmarkedMessages ===== + + @Test + fun `getAllBookmarkedMessages should return empty list when no bookmarks exist`() { + messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Not bookmarked"), + ) + + val result = messageRepository.getAllBookmarkedMessages() + + assertTrue(result.isEmpty()) + } + + @Test + fun `getAllBookmarkedMessages should return bookmarks from all sessions`() { + val otherSession = sessionRepository.createSession(ChatSession(id = "", title = "Other Session")) + try { + val baseTime = Instant.now() + val msg1 = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Session 1 bookmark", createdAt = baseTime), + ) + val msg2 = messageRepository.addMessage( + ChatMessage(id = "", sessionId = otherSession.id, role = MessageRole.USER, content = "Session 2 bookmark", createdAt = baseTime.plusSeconds(1)), + ) + messageRepository.toggleBookmark(msg1.id) + messageRepository.toggleBookmark(msg2.id) + + val result = messageRepository.getAllBookmarkedMessages() + + assertEquals(2, result.size) + assertTrue(result.any { it.id == msg1.id }) + assertTrue(result.any { it.id == msg2.id }) + } finally { + sessionRepository.deleteSession(otherSession.id) + } + } + + @Test + fun `getAllBookmarkedMessages should not include non-bookmarked messages`() { + val bookmarked = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Bookmarked"), + ) + messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.ASSISTANT, content = "Not bookmarked"), + ) + messageRepository.toggleBookmark(bookmarked.id) + + val result = messageRepository.getAllBookmarkedMessages() + + assertEquals(1, result.size) + assertEquals(bookmarked.id, result[0].id) + } + + // ===== getBookmarkCountsBySession ===== + + @Test + fun `getBookmarkCountsBySession should return empty map when no bookmarks exist`() { + messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Not bookmarked"), + ) + + val result = messageRepository.getBookmarkCountsBySession() + + assertTrue(result.isEmpty()) + } + + @Test + fun `getBookmarkCountsBySession should return correct count for single session`() { + val baseTime = Instant.now() + val msg1 = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "A", createdAt = baseTime), + ) + val msg2 = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.ASSISTANT, content = "B", createdAt = baseTime.plusSeconds(1)), + ) + messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "C (not bookmarked)", createdAt = baseTime.plusSeconds(2)), + ) + messageRepository.toggleBookmark(msg1.id) + messageRepository.toggleBookmark(msg2.id) + + val result = messageRepository.getBookmarkCountsBySession() + + assertEquals(1, result.size) + assertEquals(2, result[testSession.id]) + } + + @Test + fun `getBookmarkCountsBySession should return counts for multiple sessions`() { + val otherSession = sessionRepository.createSession(ChatSession(id = "", title = "Other Session")) + try { + val baseTime = Instant.now() + val m1 = messageRepository.addMessage(ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "S1-1", createdAt = baseTime)) + val m2 = messageRepository.addMessage(ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.ASSISTANT, content = "S1-2", createdAt = baseTime.plusSeconds(1))) + val m3 = messageRepository.addMessage(ChatMessage(id = "", sessionId = otherSession.id, role = MessageRole.USER, content = "S2-1", createdAt = baseTime.plusSeconds(2))) + messageRepository.toggleBookmark(m1.id) + messageRepository.toggleBookmark(m2.id) + messageRepository.toggleBookmark(m3.id) + + val result = messageRepository.getBookmarkCountsBySession() + + assertEquals(2, result.size) + assertEquals(2, result[testSession.id]) + assertEquals(1, result[otherSession.id]) + } finally { + sessionRepository.deleteSession(otherSession.id) + } + } + + @Test + fun `getBookmarkCountsBySession should not include sessions with zero bookmarks`() { + val otherSession = sessionRepository.createSession(ChatSession(id = "", title = "Unbookmarked Session")) + try { + val bookmarked = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Bookmarked"), + ) + messageRepository.addMessage( + ChatMessage(id = "", sessionId = otherSession.id, role = MessageRole.USER, content = "Not bookmarked"), + ) + messageRepository.toggleBookmark(bookmarked.id) + + val result = messageRepository.getBookmarkCountsBySession() + + assertEquals(1, result.size) + assertTrue(result.containsKey(testSession.id)) + assertFalse(result.containsKey(otherSession.id)) + } finally { + sessionRepository.deleteSession(otherSession.id) + } + } + + // ===== getAllBookmarkedWithSessions ===== + + @Test + fun `getAllBookmarkedWithSessions should return empty list when no bookmarks exist`() { + messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Not bookmarked"), + ) + + val result = messageRepository.getAllBookmarkedWithSessions() + + assertTrue(result.isEmpty()) + } + + @Test + fun `getAllBookmarkedWithSessions should return message paired with its session`() { + val msg = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Bookmarked"), + ) + messageRepository.toggleBookmark(msg.id) + + val result = messageRepository.getAllBookmarkedWithSessions() + + assertEquals(1, result.size) + assertEquals(msg.id, result[0].first.id) + assertEquals(testSession.id, result[0].second.id) + assertEquals(testSession.title, result[0].second.title) + } + + @Test + fun `getAllBookmarkedWithSessions should not include non-bookmarked messages`() { + val bookmarked = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Bookmarked"), + ) + messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.ASSISTANT, content = "Not bookmarked"), + ) + messageRepository.toggleBookmark(bookmarked.id) + + val result = messageRepository.getAllBookmarkedWithSessions() + + assertEquals(1, result.size) + assertEquals(bookmarked.id, result[0].first.id) + } + + @Test + fun `getAllBookmarkedWithSessions should order by session updatedAt descending then message createdAt ascending`() { + val baseTime = Instant.now() + + val olderSession = sessionRepository.createSession(ChatSession(id = "", title = "Older Session")) + try { + val olderMsg = messageRepository.addMessage( + ChatMessage(id = "", sessionId = olderSession.id, role = MessageRole.USER, content = "Older", createdAt = baseTime), + ) + messageRepository.toggleBookmark(olderMsg.id) + + Thread.sleep(50) + + // testSession gets touched last because we add a message to it after olderSession + val newerMsg1 = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Newer-1", createdAt = baseTime.plusSeconds(1)), + ) + val newerMsg2 = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.ASSISTANT, content = "Newer-2", createdAt = baseTime.plusSeconds(2)), + ) + messageRepository.toggleBookmark(newerMsg1.id) + messageRepository.toggleBookmark(newerMsg2.id) + // touch testSession so its updatedAt is after olderSession + sessionRepository.touchSession(testSession.id) + + val result = messageRepository.getAllBookmarkedWithSessions() + + assertEquals(3, result.size) + // First two rows belong to testSession (more recently updated) + assertEquals(testSession.id, result[0].second.id) + assertEquals("Newer-1", result[0].first.content) + assertEquals(testSession.id, result[1].second.id) + assertEquals("Newer-2", result[1].first.content) + // Last row belongs to olderSession + assertEquals(olderSession.id, result[2].second.id) + assertEquals("Older", result[2].first.content) + } finally { + sessionRepository.deleteSession(olderSession.id) + } + } + + @Test + fun `getAllBookmarkedWithSessions should include rows from multiple sessions`() { + val otherSession = sessionRepository.createSession(ChatSession(id = "", title = "Other Session")) + try { + val baseTime = Instant.now() + val msg1 = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "S1", createdAt = baseTime), + ) + val msg2 = messageRepository.addMessage( + ChatMessage(id = "", sessionId = otherSession.id, role = MessageRole.USER, content = "S2", createdAt = baseTime.plusSeconds(1)), + ) + messageRepository.toggleBookmark(msg1.id) + messageRepository.toggleBookmark(msg2.id) + + val result = messageRepository.getAllBookmarkedWithSessions() + + assertEquals(2, result.size) + val sessionIds = result.map { it.second.id }.toSet() + assertTrue(sessionIds.contains(testSession.id)) + assertTrue(sessionIds.contains(otherSession.id)) + } finally { + sessionRepository.deleteSession(otherSession.id) + } + } + + @Test + fun `getAllBookmarkedWithSessions should reflect un-bookmarking immediately`() { + val msg = messageRepository.addMessage( + ChatMessage(id = "", sessionId = testSession.id, role = MessageRole.USER, content = "Toggle"), + ) + messageRepository.toggleBookmark(msg.id) // on + assertEquals(1, messageRepository.getAllBookmarkedWithSessions().size) + + messageRepository.toggleBookmark(msg.id) // off + assertTrue(messageRepository.getAllBookmarkedWithSessions().isEmpty()) + } } diff --git a/cli/src/test/kotlin/io/askimo/core/chat/service/ChatSessionServiceIT.kt b/cli/src/test/kotlin/io/askimo/core/chat/service/ChatSessionServiceIT.kt index 9db5dd805..ff3cb1f97 100644 --- a/cli/src/test/kotlin/io/askimo/core/chat/service/ChatSessionServiceIT.kt +++ b/cli/src/test/kotlin/io/askimo/core/chat/service/ChatSessionServiceIT.kt @@ -526,4 +526,150 @@ class ChatSessionServiceIT { secondResult.messages.map { it.content }, ) } + + // ------------------------------------------------------------------------- + // getAllBookmarkGroups + // ------------------------------------------------------------------------- + + @Test + fun `getAllBookmarkGroups should return empty list when no bookmarks exist`() { + sessionRepository.createSession(ChatSession(id = "", title = "No Bookmarks")) + + val groups = service.getAllBookmarkGroups() + + assertTrue(groups.isEmpty()) + } + + @Test + fun `getAllBookmarkGroups should return empty list when messages exist but none are bookmarked`() { + val session = sessionRepository.createSession(ChatSession(id = "", title = "Unbookmarked")) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Not bookmarked")) + + val groups = service.getAllBookmarkGroups() + + assertTrue(groups.isEmpty()) + } + + @Test + fun `getAllBookmarkGroups should return one group with bookmarked message`() { + val session = sessionRepository.createSession(ChatSession(id = "", title = "Single Bookmark")) + val msg = service.addMessage( + ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Bookmarked"), + ) + service.toggleBookmark(msg.id) + + val groups = service.getAllBookmarkGroups() + + assertEquals(1, groups.size) + assertEquals(session.id, groups[0].session.id) + assertEquals(1, groups[0].messages.size) + assertEquals("Bookmarked", groups[0].messages[0].content) + } + + @Test + fun `getAllBookmarkGroups should exclude non-bookmarked messages from the group`() { + val session = sessionRepository.createSession(ChatSession(id = "", title = "Mixed")) + val bookmarked = service.addMessage( + ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Bookmarked", createdAt = Instant.now()), + ) + service.addMessage( + ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "Not bookmarked", createdAt = Instant.now().plusSeconds(1)), + ) + service.toggleBookmark(bookmarked.id) + + val groups = service.getAllBookmarkGroups() + + assertEquals(1, groups.size) + assertEquals(1, groups[0].messages.size) + assertEquals("Bookmarked", groups[0].messages[0].content) + } + + @Test + fun `getAllBookmarkGroups should order messages within a group by createdAt ascending`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Message Order")) + + val first = service.addMessage( + ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "First", createdAt = baseTime), + ) + val second = service.addMessage( + ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "Second", createdAt = baseTime.plusSeconds(1)), + ) + val third = service.addMessage( + ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Third", createdAt = baseTime.plusSeconds(2)), + ) + service.toggleBookmark(first.id) + service.toggleBookmark(second.id) + service.toggleBookmark(third.id) + + val groups = service.getAllBookmarkGroups() + + assertEquals(1, groups.size) + val contents = groups[0].messages.map { it.content } + assertEquals(listOf("First", "Second", "Third"), contents) + } + + @Test + fun `getAllBookmarkGroups should return multiple sessions ordered by updatedAt descending`() { + val baseTime = Instant.now() + + val olderSession = sessionRepository.createSession(ChatSession(id = "", title = "Older Session")) + val msg1 = service.addMessage( + ChatMessage(id = "", sessionId = olderSession.id, role = MessageRole.USER, content = "Older bookmark", createdAt = baseTime), + ) + service.toggleBookmark(msg1.id) + + Thread.sleep(50) + + val newerSession = sessionRepository.createSession(ChatSession(id = "", title = "Newer Session")) + val msg2 = service.addMessage( + ChatMessage(id = "", sessionId = newerSession.id, role = MessageRole.USER, content = "Newer bookmark", createdAt = baseTime.plusSeconds(1)), + ) + service.toggleBookmark(msg2.id) + + val groups = service.getAllBookmarkGroups() + + assertEquals(2, groups.size) + // Newer session (more recently updated) must come first + assertEquals(newerSession.id, groups[0].session.id) + assertEquals(olderSession.id, groups[1].session.id) + } + + @Test + fun `getAllBookmarkGroups should group bookmarks correctly across multiple sessions`() { + val session1 = sessionRepository.createSession(ChatSession(id = "", title = "Session 1")) + val session2 = sessionRepository.createSession(ChatSession(id = "", title = "Session 2")) + + val a = service.addMessage(ChatMessage(id = "", sessionId = session1.id, role = MessageRole.USER, content = "S1-A")) + val b = service.addMessage(ChatMessage(id = "", sessionId = session1.id, role = MessageRole.ASSISTANT, content = "S1-B")) + val c = service.addMessage(ChatMessage(id = "", sessionId = session2.id, role = MessageRole.USER, content = "S2-A")) + service.addMessage(ChatMessage(id = "", sessionId = session2.id, role = MessageRole.ASSISTANT, content = "S2-B-not-bookmarked")) + + service.toggleBookmark(a.id) + service.toggleBookmark(b.id) + service.toggleBookmark(c.id) + + val groups = service.getAllBookmarkGroups() + + assertEquals(2, groups.size) + val groupBySessionId = groups.associateBy { it.session.id } + + assertEquals(2, groupBySessionId[session1.id]!!.messages.size) + assertEquals(1, groupBySessionId[session2.id]!!.messages.size) + assertEquals("S2-A", groupBySessionId[session2.id]!!.messages[0].content) + } + + @Test + fun `getAllBookmarkGroups should reflect toggle — removed bookmark disappears from group`() { + val session = sessionRepository.createSession(ChatSession(id = "", title = "Toggle Test")) + val msg = service.addMessage( + ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Toggle me"), + ) + + service.toggleBookmark(msg.id) // bookmark on + assertEquals(1, service.getAllBookmarkGroups().size) + + service.toggleBookmark(msg.id) // bookmark off + assertTrue(service.getAllBookmarkGroups().isEmpty()) + } } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksView.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksView.kt new file mode 100644 index 000000000..f2e513df5 --- /dev/null +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksView.kt @@ -0,0 +1,336 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.ui.bookmarks + +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.askimo.core.util.TimeUtil +import io.askimo.ui.common.i18n.stringResource +import io.askimo.ui.common.theme.AppComponents +import io.askimo.ui.common.theme.Spacing +import io.askimo.ui.common.theme.ThemePreferences +import io.askimo.ui.common.ui.markdownText + +/** + * Full content-area view that lists all bookmarked messages across every session, + * grouped by conversation. Newest-activity conversation first. + * + * @param viewModel Provides [BookmarksViewModel.groups], loading, and error state. + * @param onNavigateToSession Called with a sessionId when the user wants to jump to a conversation. + * @param modifier Outer modifier. + */ +@Composable +fun bookmarksView( + viewModel: BookmarksViewModel, + onNavigateToSession: (sessionId: String) -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(end = 8.dp) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .widthIn(max = ThemePreferences.CONTENT_MAX_WIDTH) + .fillMaxWidth() + .padding(start = 24.dp, end = 36.dp, top = 24.dp, bottom = 24.dp), + ) { + // ── Header ──────────────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Bookmark, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Text( + text = stringResource("bookmarks.title"), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + Spacer(modifier = Modifier.height(Spacing.large)) + + // ── Body ────────────────────────────────────────────────────── + when { + viewModel.isLoading -> { + Box( + modifier = Modifier.fillMaxWidth().height(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + viewModel.errorMessage != null -> { + Box( + modifier = Modifier.fillMaxWidth().height(200.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.small), + ) { + Text( + text = viewModel.errorMessage ?: "", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + TextButton(onClick = viewModel::load) { + Text(stringResource("action.retry")) + } + } + } + } + + viewModel.groups.isEmpty() -> { + Box( + modifier = Modifier.fillMaxWidth().height(300.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.medium), + ) { + Icon( + imageVector = Icons.Default.BookmarkBorder, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + Text( + text = stringResource("bookmarks.empty.title"), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource("bookmarks.empty.hint"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + else -> { + bookmarkGroupList( + groups = viewModel.groups, + onNavigateToSession = onNavigateToSession, + onRemoveBookmark = viewModel::removeBookmark, + ) + } + } + } + } + + // Scrollbar + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + adapter = rememberScrollbarAdapter(scrollState), + style = AppComponents.scrollbarStyle(), + ) + } +} + +// ── Group list ──────────────────────────────────────────────────────────────── + +@Composable +private fun bookmarkGroupList( + groups: List, + onNavigateToSession: (String) -> Unit, + onRemoveBookmark: (String) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Spacing.large), + ) { + groups.forEach { group -> + bookmarkGroupCard( + group = group, + onNavigateToSession = onNavigateToSession, + onRemoveBookmark = onRemoveBookmark, + ) + } + } +} + +@Composable +private fun bookmarkGroupCard( + group: io.askimo.core.chat.service.BookmarkGroup, + onNavigateToSession: (String) -> Unit, + onRemoveBookmark: (String) -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = AppComponents.sidebarSurfaceColor(), + shape = MaterialTheme.shapes.medium, + ) { + Column { + // ── Session header ──────────────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onNavigateToSession(group.session.id) } + .pointerHoverIcon(PointerIcon.Hand) + .padding(horizontal = Spacing.large, vertical = Spacing.medium), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = group.session.title.ifBlank { stringResource("bookmarks.session.untitled") }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource("bookmarks.message.count", group.messages.size), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + + // ── Bookmarked messages ─────────────────────────────────────── + group.messages.forEach { message -> + bookmarkMessageRow( + content = message.content, + timestamp = message.timestamp, + isUser = message.isUser, + onJumpToSession = { onNavigateToSession(group.session.id) }, + onRemove = { message.id?.let { onRemoveBookmark(it) } }, + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = Spacing.large), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + ) + } + } + } +} + +@Composable +private fun bookmarkMessageRow( + content: String, + timestamp: java.time.Instant?, + isUser: Boolean, + onJumpToSession: () -> Unit, + onRemove: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onJumpToSession) + .pointerHoverIcon(PointerIcon.Hand, overrideDescendants = true) + .padding(horizontal = Spacing.large, vertical = Spacing.medium), + horizontalArrangement = Arrangement.spacedBy(Spacing.medium), + verticalAlignment = Alignment.Top, + ) { + // Bookmark icon — always visible; hover switches to BookmarkBorder; click removes + val bookmarkHoverSource = remember { MutableInteractionSource() } + val isBookmarkHovered by bookmarkHoverSource.collectIsHoveredAsState() + Box( + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + .hoverable(bookmarkHoverSource) + .pointerHoverIcon(PointerIcon.Hand) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onRemove, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (isBookmarkHovered) Icons.Default.BookmarkBorder else Icons.Default.Bookmark, + contentDescription = stringResource("message.bookmark.remove"), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = if (isBookmarkHovered) 1f else 0.7f), + ) + } + + Column(modifier = Modifier.weight(1f)) { + // Role label + Text( + text = if (isUser) stringResource("bookmarks.role.user") else stringResource("bookmarks.role.ai"), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + Spacer(modifier = Modifier.height(2.dp)) + // Message preview — rendered as markdown + markdownText( + markdown = content.take(600), + modifier = Modifier.fillMaxWidth(), + ) + timestamp?.let { ts -> + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = TimeUtil.formatFullDateTime(ts, java.util.Locale.getDefault()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + } + } +} 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 new file mode 100644 index 000000000..fdd079650 --- /dev/null +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksViewModel.kt @@ -0,0 +1,83 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.ui.bookmarks + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.askimo.core.chat.service.BookmarkGroup +import io.askimo.core.chat.service.ChatSessionService +import io.askimo.core.logging.logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * ViewModel for the global Bookmarks view. + * + * Loads all bookmarked messages across every session and groups them + * by conversation. UI observes [groups], [isLoading], and [errorMessage]. + */ +class BookmarksViewModel( + private val chatSessionService: ChatSessionService, + private val scope: CoroutineScope, +) { + private val log = logger() + + var groups by mutableStateOf>(emptyList()) + private set + + var isLoading by mutableStateOf(false) + private set + + var errorMessage by mutableStateOf(null) + private set + + init { + load() + } + + fun load() { + scope.launch { + isLoading = true + errorMessage = null + try { + val result = withContext(Dispatchers.IO) { + chatSessionService.getAllBookmarkGroups() + } + groups = result + } catch (e: Exception) { + log.error("Failed to load bookmarks", e) + errorMessage = e.message ?: "Failed to load bookmarks" + } finally { + isLoading = false + } + } + } + + /** + * Remove a single bookmark optimistically from the in-memory list, + * then persist the change via [chatSessionService]. + */ + fun removeBookmark(messageId: String) { + // Optimistic update + groups = groups.mapNotNull { group -> + val updated = group.messages.filter { it.id != messageId } + if (updated.isEmpty()) null else group.copy(messages = updated) + } + + scope.launch { + try { + withContext(Dispatchers.IO) { + chatSessionService.toggleBookmark(messageId) + } + } 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/ChatActions.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatActions.kt index 7535d627d..0ff6ccaa9 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatActions.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatActions.kt @@ -24,4 +24,6 @@ interface ChatActions { fun setDirective(directiveId: String?) fun updateAIMessage(messageId: String, newContent: String) fun retryMessage(messageId: String, enabledServerIds: Set = emptySet()) + fun toggleBookmark(messageId: String) + fun clearPendingScroll() } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatState.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatState.kt index 9332017de..917700c11 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatState.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatState.kt @@ -44,4 +44,11 @@ data class ChatState( // Tool call state — ephemeral, populated only during active streaming val activeToolCalls: List = emptyList(), + + // Bookmark state — IDs of messages pinned by the user in this session + val bookmarkedMessageIds: Set = emptySet(), + + // Set by jumpToMessage so ChatView can scroll to the target after messages reload. + // Cleared by ChatView after consuming via actions.clearPendingScroll(). + val pendingScrollToMessageId: String? = null, ) diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt index 3b88dca37..7dfcf7543 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt @@ -7,8 +7,12 @@ package io.askimo.ui.chat import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.focusable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,6 +30,8 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.KeyboardArrowDown @@ -35,6 +41,8 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -101,6 +109,9 @@ import io.askimo.ui.common.theme.AppComponents import io.askimo.ui.common.theme.LocalBackgroundActive import io.askimo.ui.common.theme.Spacing import io.askimo.ui.common.theme.ThemePreferences +import io.askimo.ui.common.ui.TooltipPlacement +import io.askimo.ui.common.ui.markdownText +import io.askimo.ui.common.ui.themedRichTooltip import io.askimo.ui.common.ui.themedTooltip import io.askimo.ui.common.ui.util.FileDialogUtils import io.askimo.ui.service.AvatarService @@ -167,6 +178,8 @@ fun chatView( val sessionTitle = state.sessionTitle val project = state.project val activeToolCalls = state.activeToolCalls + val bookmarkedMessageIds = state.bookmarkedMessageIds + val pendingScrollToMessageId = state.pendingScrollToMessageId // Internal state management for ChatView val scope = rememberCoroutineScope() @@ -632,6 +645,190 @@ fun chatView( horizontalArrangement = Arrangement.spacedBy(Spacing.small), verticalAlignment = Alignment.CenterVertically, ) { + // Bookmark popover — always visible + var showBookmarksPopover by remember { mutableStateOf(false) } + val pinnedMessages = messages.filter { + it.id != null && it.id in bookmarkedMessageIds + } + // Auto-close popover when the last pinned message is removed + LaunchedEffect(pinnedMessages.isEmpty()) { + if (pinnedMessages.isEmpty()) { + showBookmarksPopover = false + } + } + Box { + themedTooltip( + text = if (pinnedMessages.isNotEmpty()) { + "${stringResource("chat.bookmarks.button.tooltip")} (${pinnedMessages.size})" + } else { + stringResource("chat.bookmarks.button.tooltip") + }, + ) { + IconButton( + onClick = { showBookmarksPopover = true }, + modifier = Modifier + .size(36.dp) + .pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + imageVector = if (pinnedMessages.isNotEmpty()) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = stringResource("chat.bookmarks.button.tooltip"), + modifier = Modifier.size(20.dp), + tint = if (pinnedMessages.isNotEmpty()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + DropdownMenu( + expanded = showBookmarksPopover, + onDismissRequest = { showBookmarksPopover = false }, + modifier = Modifier.widthIn(min = 480.dp), + ) { + if (pinnedMessages.isEmpty()) { + // Empty state + DropdownMenuItem( + text = { + Column( + modifier = Modifier + .widthIn(min = 220.dp, max = 320.dp) + .padding(vertical = Spacing.small), + verticalArrangement = Arrangement.spacedBy(Spacing.small), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.BookmarkBorder, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource("chat.bookmarks.popover.empty.title"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + text = stringResource("chat.bookmarks.popover.empty.hint"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = {}, + enabled = false, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = Spacing.large, + vertical = Spacing.medium, + ), + ) + } else { + Text( + text = "${stringResource("chat.bookmarks.popover.title")} (${pinnedMessages.size})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Spacing.large, vertical = Spacing.medium), + ) + HorizontalDivider() + pinnedMessages.forEachIndexed { index, msg -> + if (index > 0) HorizontalDivider() + themedRichTooltip( + placement = TooltipPlacement.LEFT, + tooltipContent = { + markdownText( + markdown = msg.content.take(600), + modifier = Modifier + .widthIn(max = 360.dp) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) + }, + ) { + DropdownMenuItem( + text = { + Row( + modifier = Modifier + .widthIn(min = 400.dp, max = 560.dp) + .padding(vertical = Spacing.small), + horizontalArrangement = Arrangement.spacedBy(Spacing.small), + verticalAlignment = Alignment.Top, + ) { + // Bookmark icon — top-aligned, clicking removes pin + val bookmarkIconHoverSource = remember { MutableInteractionSource() } + val isBookmarkIconHovered by bookmarkIconHoverSource.collectIsHoveredAsState() + Box( + modifier = Modifier + .padding(top = 4.dp) + .size(24.dp) + .hoverable(bookmarkIconHoverSource) + .pointerHoverIcon(PointerIcon.Hand) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + val id = msg.id + if (id != null) { + actions.toggleBookmark(id) + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (isBookmarkIconHovered) { + Icons.Default.BookmarkBorder + } else { + Icons.Default.Bookmark + }, + contentDescription = stringResource("message.bookmark.remove"), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + // Message preview + timestamp + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall), + ) { + markdownText( + markdown = msg.content.take(120), + modifier = Modifier.fillMaxWidth(), + ) + msg.timestamp?.let { ts -> + Text( + text = formatDisplay(ts), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + } + }, + onClick = { + val id = msg.id + val ts = msg.timestamp + if (id != null && ts != null) { + onJumpToMessage(id, ts) + } + showBookmarksPopover = false + }, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = Spacing.large, + vertical = Spacing.medium, + ), + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand, overrideDescendants = true), + ) + } // end themedRichTooltip + } + } + } + } + if (sessionId != null && messages.isNotEmpty()) { sessionActionsMenu( sessionId = sessionId, @@ -833,6 +1030,22 @@ fun chatView( } } + // Scroll to a bookmarked or jump-target message after jumpToMessage loads context + LaunchedEffect(pendingScrollToMessageId) { + val targetId = pendingScrollToMessageId ?: return@LaunchedEffect + if (messages.isNotEmpty() && messagesScrollState.maxValue > 0) { + val targetIndex = messages.indexOfFirst { it.id == targetId } + if (targetIndex >= 0) { + val estimatedPosition = ( + targetIndex.toFloat() / messages.size * + messagesScrollState.maxValue + ).toInt().coerceIn(0, messagesScrollState.maxValue) + messagesScrollState.animateScrollTo(estimatedPosition) + } + } + actions.clearPendingScroll() + } + // ── Messages area ──────────────────────────────────────────────── // The outer Box is the scroll container — the full chat area scrolls, // not just the inner column. This means scroll works regardless of @@ -927,6 +1140,8 @@ fun chatView( viewportTopY = viewportBounds?.top, projectId = project?.id, activeToolCalls = activeToolCalls, + bookmarkedMessageIds = bookmarkedMessageIds, + onToggleBookmark = { messageId -> actions.toggleBookmark(messageId) }, ) } } 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 92eb5875d..3a9d97204 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 @@ -114,6 +114,12 @@ class ChatViewModel( var activeToolCalls by mutableStateOf>(emptyList()) private set + var bookmarkedMessageIds by mutableStateOf>(emptySet()) + private set + + var pendingScrollToMessageId by mutableStateOf(null) + private set + val state: ChatState get() = ChatState( messages = messages, @@ -134,6 +140,8 @@ class ChatViewModel( sessionTitle = sessionTitle ?: "", project = project, activeToolCalls = activeToolCalls, + bookmarkedMessageIds = bookmarkedMessageIds, + pendingScrollToMessageId = pendingScrollToMessageId, ) /** @@ -855,6 +863,13 @@ class ChatViewModel( sessionTitle = result.title project = result.project + // Load bookmark IDs for this session so the UI can mark pinned messages + bookmarkedMessageIds = withContext(Dispatchers.IO) { + chatSessionService.getBookmarkedMessages(sessionId) + .mapNotNull { it.id } + .toSet() + } + // Reset thinking state isThinking = false stopThinkingTimer() @@ -1083,6 +1098,8 @@ class ChatViewModel( hasMoreMessages = currentCursor != null isLoading = false + // Signal ChatView to scroll to the target message after recomposition + pendingScrollToMessageId = messageId } catch (e: Exception) { errorMessage = ErrorHandler.getUserFriendlyError(e, "jumping to message", "Failed to navigate to message. Please try again.") isLoading = false @@ -1274,4 +1291,40 @@ class ChatViewModel( log.debug("Cleaned up ChatViewModel for session: ${currentSessionId.value}") } + + /** + * Toggle the bookmark state of a message. + * Updates the DB on a background thread and reflects the change immediately in UI state + * for instant feedback (optimistic update). + */ + override fun toggleBookmark(messageId: String) { + bookmarkedMessageIds = if (messageId in bookmarkedMessageIds) { + bookmarkedMessageIds - messageId + } else { + bookmarkedMessageIds + messageId + } + + scope.launch { + try { + withContext(Dispatchers.IO) { + chatSessionService.toggleBookmark(messageId) + } + } catch (e: Exception) { + // Roll back the optimistic update on failure + bookmarkedMessageIds = if (messageId in bookmarkedMessageIds) { + bookmarkedMessageIds - messageId + } else { + bookmarkedMessageIds + messageId + } + log.error("Failed to toggle bookmark for message {}", messageId, e) + } + } + } + + /** + * Consume the pending scroll target after ChatView has scrolled to it. + */ + override fun clearPendingScroll() { + pendingScrollToMessageId = null + } } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt index 8b4096b50..7c79e1e21 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt @@ -4,7 +4,6 @@ */ package io.askimo.ui.chat -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background @@ -32,6 +31,8 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronRight @@ -110,9 +111,6 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.Locale import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalComposeUiApi::class) @@ -134,6 +132,8 @@ fun messageList( viewportTopY: Float? = null, projectId: String? = null, activeToolCalls: List = emptyList(), + bookmarkedMessageIds: Set = emptySet(), + onToggleBookmark: ((String) -> Unit)? = null, ) { // Retry confirmation dialog state var showRetryConfirmDialog by remember { mutableStateOf(false) } @@ -176,8 +176,6 @@ fun messageList( // Group messages into active and outdated branches val messageGroups = groupMessagesWithOutdatedBranches(messages) - val locale = LocalizationManager.getCurrentLocale() - var messageIndex = 0 var isFirstMessage = true var lastDayLabel: String? = null @@ -187,7 +185,7 @@ fun messageList( // Day separator — show when the message date is different from the previous one val ts = group.message.timestamp if (ts != null) { - val dayLabel = formatDayLabel(ts, locale, currentDate) + val dayLabel = LocalizationManager.formatDayLabel(ts, currentDate) if (dayLabel != lastDayLabel) { messageDaySeparator(label = dayLabel) lastDayLabel = dayLabel @@ -227,6 +225,8 @@ fun messageList( isStreaming = isStreamingMessage, projectId = projectId, toolCalls = if (isLastAiMsg) activeToolCalls else emptyList(), + bookmarkedMessageIds = bookmarkedMessageIds, + onToggleBookmark = onToggleBookmark, ) isFirstMessage = false messageIndex++ @@ -322,6 +322,8 @@ fun messageBubble( isStreaming: Boolean = false, projectId: String? = null, toolCalls: List = emptyList(), + bookmarkedMessageIds: Set = emptySet(), + onToggleBookmark: ((String) -> Unit)? = null, ) { Box( modifier = Modifier @@ -338,6 +340,8 @@ fun messageBubble( onDownloadAttachment = onDownloadAttachment, userAvatarPainter = userAvatarPainter, isOutdatedMessage = isOutdatedMessage, + isBookmarked = message.id != null && message.id in bookmarkedMessageIds, + onToggleBookmark = if (message.id != null) onToggleBookmark else null, ) } else { aiMessageBubble( @@ -356,6 +360,8 @@ fun messageBubble( isStreaming = isStreaming, projectId = projectId, toolCalls = toolCalls, + isBookmarked = message.id != null && message.id in bookmarkedMessageIds, + onToggleBookmark = if (message.id != null) onToggleBookmark else null, ) } } @@ -372,6 +378,8 @@ private fun userMessageBubble( onDownloadAttachment: ((FileAttachmentDTO) -> Unit)? = null, userAvatarPainter: BitmapPainter? = null, isOutdatedMessage: Boolean = false, + isBookmarked: Boolean = false, + onToggleBookmark: ((String) -> Unit)? = null, ) { val clipboardManager = LocalClipboardManager.current var isHovered by remember { mutableStateOf(false) } @@ -541,7 +549,7 @@ private fun userMessageBubble( clipboardManager.setText(AnnotatedString(message.content)) showCopyFeedback = true coroutineScope.launch { - delay(2000) + delay(2000.milliseconds) showCopyFeedback = false } }, @@ -572,12 +580,20 @@ private fun userMessageBubble( } } + if (onToggleBookmark != null && message.id != null) { + bookmarkToggleButton( + msgId = message.id!!, + isBookmarked = isBookmarked, + onToggleBookmark = onToggleBookmark, + ) + } + // Timestamp — visible on hover, right side of action bar message.timestamp?.let { ts -> Spacer(modifier = Modifier.width(Spacing.small)) themedTooltip(text = TimeUtil.formatFullDateTime(ts, LocalizationManager.getCurrentLocale())) { Text( - text = formatMessageTime(ts), + text = LocalizationManager.formatMessageTime(ts), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) @@ -612,6 +628,8 @@ private fun aiMessageBubble( isStreaming: Boolean = false, projectId: String? = null, toolCalls: List = emptyList(), + isBookmarked: Boolean = false, + onToggleBookmark: ((String) -> Unit)? = null, ) { val clipboardManager = LocalClipboardManager.current var showCopyFeedback by remember { mutableStateOf(false) } @@ -789,6 +807,19 @@ private fun aiMessageBubble( } } } + + // Bookmark badge — always visible on pinned messages + if (isBookmarked) { + Icon( + imageVector = Icons.Default.Bookmark, + contentDescription = stringResource("message.bookmark.description"), + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 4.dp, end = 4.dp) + .size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } } } @@ -914,6 +945,30 @@ private fun aiMessageBubble( ) } } + + // Bookmark toggle + if (onToggleBookmark != null && message.id != null) { + val msgId = message.id!! + themedTooltip( + text = if (isBookmarked) stringResource("message.bookmark.remove") else stringResource("message.bookmark"), + ) { + IconButton( + onClick = { onToggleBookmark.invoke(msgId) }, + modifier = Modifier.size(32.dp).pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + imageVector = if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = stringResource("message.bookmark.description"), + modifier = Modifier.size(16.dp), + tint = if (isBookmarked) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } } } @@ -972,7 +1027,7 @@ private fun aiMessageBubble( val timestampPrefix = if (total != null && total > 0) "· " else "" themedTooltip(text = TimeUtil.formatFullDateTime(ts, LocalizationManager.getCurrentLocale())) { Text( - text = "$timestampPrefix${formatMessageTime(ts)}", + text = "$timestampPrefix${LocalizationManager.formatMessageTime(ts)}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) @@ -1307,7 +1362,6 @@ private fun fileAttachmentChip( } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun aiMessageEditDialog( message: ChatMessageDTO, @@ -1396,28 +1450,6 @@ fun aiMessageEditDialog( } } -/** Formats an [Instant] as "HH:mm" in the system default time zone using the current locale. */ -private fun formatMessageTime(timestamp: Instant): String { - val formatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) - .withLocale(LocalizationManager.getCurrentLocale()) - .withZone(ZoneId.systemDefault()) - return formatter.format(timestamp) -} - -/** Formats an [Instant] as a day label relative to today (Today / Yesterday / full date). */ -private fun formatDayLabel(timestamp: Instant, locale: Locale, currentDate: LocalDate): String { - val zone = ZoneId.systemDefault() - return when (val msgDate = timestamp.atZone(zone).toLocalDate()) { - currentDate -> LocalizationManager.getString("message.date.today") - - currentDate.minusDays(1) -> LocalizationManager.getString("message.date.yesterday") - - else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) - .withLocale(locale) - .format(msgDate) - } -} - /** Day separator shown between messages from different calendar days. */ @Composable private fun messageDaySeparator(label: String) { @@ -1443,3 +1475,30 @@ private fun messageDaySeparator(label: String) { ) } } + +@Composable +private fun bookmarkToggleButton( + msgId: String, + isBookmarked: Boolean, + onToggleBookmark: (String) -> Unit, +) { + themedTooltip( + text = if (isBookmarked) stringResource("message.bookmark.remove") else stringResource("message.bookmark"), + ) { + IconButton( + onClick = { onToggleBookmark(msgId) }, + modifier = Modifier.size(32.dp).pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + imageVector = if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = stringResource("message.bookmark.description"), + modifier = Modifier.size(16.dp), + tint = if (isBookmarked) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } +} diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/keymap/KeyMapManager.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/keymap/KeyMapManager.kt index 56159ba70..5ccae0a01 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/keymap/KeyMapManager.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/keymap/KeyMapManager.kt @@ -50,6 +50,7 @@ object KeyMapManager { ENTER_FULLSCREEN("shortcut.enter.fullscreen", Key.F, requiresPrimaryModifier = true, requiresCtrl = true), NAVIGATE_TO_SESSIONS("shortcut.navigate.to.sessions", Key.E, requiresPrimaryModifier = true), NAVIGATE_TO_PROJECTS("shortcut.navigate.to.projects", Key.P, requiresPrimaryModifier = true), + NAVIGATE_TO_BOOKMARKS("shortcut.navigate.to.bookmarks", Key.B, requiresPrimaryModifier = true, requiresShift = true), // Project shortcuts CREATE_PROJECT("shortcut.create.project", Key.N, requiresPrimaryModifier = true, requiresShift = true), @@ -154,6 +155,7 @@ object KeyMapManager { LocalizationManager.getString("shortcut.category.view") to listOf( AppShortcut.NAVIGATE_TO_SESSIONS, AppShortcut.NAVIGATE_TO_PROJECTS, + AppShortcut.NAVIGATE_TO_BOOKMARKS, AppShortcut.ENTER_FULLSCREEN, ), LocalizationManager.getString("shortcut.category.project") to listOf( diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/Tooltip.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/Tooltip.kt index 4e4fcf183..7849adc3b 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/Tooltip.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/Tooltip.kt @@ -242,18 +242,22 @@ private class SmartTooltipPositionProvider( TooltipPlacement.LEFT -> { val xPos = anchorBounds.left - popupContentSize.width - TOOLTIP_ANCHOR_SPACING val constrainedX = maxOf(0, xPos) // Ensure not off-screen left + val rawY = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2 + val constrainedY = rawY.coerceIn(0, maxOf(0, maxHeightPx.toInt() - popupContentSize.height)) IntOffset( x = constrainedX, - y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2, + y = constrainedY, ) } TooltipPlacement.RIGHT -> { val xPos = anchorBounds.right + TOOLTIP_ANCHOR_SPACING val constrainedX = minOf(xPos, maxWidthPx.toInt() - popupContentSize.width) // Ensure not off-screen right + val rawY = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2 + val constrainedY = rawY.coerceIn(0, maxOf(0, maxHeightPx.toInt() - popupContentSize.height)) IntOffset( x = constrainedX, - y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2, + y = constrainedY, ) } 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 94d86864e..85910271a 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 @@ -30,6 +30,7 @@ import java.io.File import java.io.FileNotFoundException import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import kotlin.time.Duration.Companion.milliseconds /** * ViewModel for managing sessions view state and operations. @@ -63,6 +64,10 @@ class SessionsViewModel( var totalSessionCount by mutableStateOf(0) private set + /** sessionId → number of bookmarked messages. Only populated for sessions that have ≥1 bookmark. */ + var bookmarkCountsBySession by mutableStateOf>(emptyMap()) + private set + var searchQuery by mutableStateOf("") private set @@ -176,13 +181,15 @@ class SessionsViewModel( fun loadRecentSessions() { scope.launch { try { - val (sessions, total) = withContext(Dispatchers.IO) { + val (sessions, total, bookmarkCounts) = withContext(Dispatchers.IO) { val sessions = sessionService.getSessionsWithoutProject(MAX_SIDEBAR_SESSIONS) val total = sessionService.countSessionsWithoutProject() - sessions to total + val bookmarkCounts = sessionService.getBookmarkCountsBySession() + Triple(sessions, total, bookmarkCounts) } recentSessions = sessions totalSessionCount = total + bookmarkCountsBySession = bookmarkCounts log.debug("Loaded ${sessions.size} sessions without projects (showing ${sessions.size} in sidebar, total: $total)") } catch (e: Exception) { @@ -227,7 +234,7 @@ class SessionsViewModel( searchQuery = query searchDebounceJob?.cancel() searchDebounceJob = scope.launch { - delay(300) + delay(300.milliseconds) loadSessions(1) } } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NativeMenuBar.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NativeMenuBar.kt index 3333142bf..7916ca002 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NativeMenuBar.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NativeMenuBar.kt @@ -91,11 +91,12 @@ object NativeMenuBar { isSkillsVisible: Boolean = true, isProjectsVisible: Boolean = true, onShowSystemDiagnostics: () -> Unit = {}, + onNavigateToBookmarks: () -> Unit, ) { val window = frameWindowScope.window // Setup AWT menu bar for all platforms (includes Documentation) - setupAWTMenuBar(window, onShowAbout, onNewChat, onNewProject, onSearchInSessions, onShowSettings, onShowEventLog, onCheckForUpdates, onEnterFullScreen, onNavigateToSessions, onNavigateToProjects, onNavigateToDiscover, onToggleSidebar, onInvalidateCaches, onExportBackup, onImportBackup, onShowGettingStarted, onOpenTerminal, onClearPreferences, onClearAccountPreferences, onTogglePlans, onToggleSkills, onToggleProjects, isPlansVisible, isSkillsVisible, isProjectsVisible, onShowSystemDiagnostics) + setupAWTMenuBar(window, onShowAbout, onNewChat, onNewProject, onSearchInSessions, onShowSettings, onShowEventLog, onCheckForUpdates, onEnterFullScreen, onNavigateToSessions, onNavigateToProjects, onNavigateToDiscover, onToggleSidebar, onInvalidateCaches, onExportBackup, onImportBackup, onShowGettingStarted, onOpenTerminal, onClearPreferences, onClearAccountPreferences, onTogglePlans, onToggleSkills, onToggleProjects, isPlansVisible, isSkillsVisible, isProjectsVisible, onShowSystemDiagnostics, onNavigateToBookmarks) // On macOS, also register the About handler for the app menu if (Platform.isMac) { @@ -145,6 +146,7 @@ object NativeMenuBar { isSkillsVisible: Boolean, isProjectsVisible: Boolean, onShowSystemDiagnostics: () -> Unit, + onNavigateToBookmarks: () -> Unit, ) { if (window is Frame) { val menuBar = MenuBar() @@ -350,6 +352,15 @@ object NativeMenuBar { } viewMenu.add(fullScreenItem) + // Bookmarks — navigate to the global Bookmarks view + viewMenu.addSeparator() + val bookmarksItem = MenuItem( + LocalizationManager.getString("menu.view.bookmarks"), + MenuShortcut(KeyEvent.VK_B, true), // Shift+Cmd+B on Mac, Shift+Ctrl+B on others + ) + bookmarksItem.addActionListener { onNavigateToBookmarks() } + viewMenu.add(bookmarksItem) + menuBar.add(viewMenu) // Terminal Menu diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NavigationSidebar.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NavigationSidebar.kt index 1052c91ca..71b021da2 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NavigationSidebar.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NavigationSidebar.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.MenuOpen import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ExpandLess @@ -972,6 +973,7 @@ private fun sessionsList( onShowSessionSummary = onShowSessionSummary, availableProjects = availableProjects, onMoveSessionToNewProject = onMoveSessionToNewProject, + bookmarkCount = sessionsViewModel.bookmarkCountsBySession[session.id] ?: 0, ) } @@ -1016,6 +1018,7 @@ private fun sessionItemWithMenu( onShowSessionSummary: (String) -> Unit = {}, availableProjects: List, onMoveSessionToNewProject: (sessionId: String) -> Unit, + bookmarkCount: Int = 0, ) { var showMenu by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } @@ -1047,6 +1050,7 @@ private fun sessionItemWithMenu( isHovered = isHovered || showMenu, onResumeSession = onResumeSession, onMenuClick = { showMenu = true }, + bookmarkCount = bookmarkCount, ) Box(modifier = Modifier.align(Alignment.CenterEnd).padding(end = Spacing.small)) { @@ -1093,6 +1097,7 @@ private fun sessionDrawerItemContent( isHovered: Boolean, onResumeSession: (String) -> Unit, onMenuClick: () -> Unit, + bookmarkCount: Int = 0, ) { val fontScale = LocalFontScale.current @@ -1114,6 +1119,7 @@ private fun sessionDrawerItemContent( text = session.title, onMenuClick = onMenuClick, isHovered = isHovered, + bookmarkCount = bookmarkCount, ) }, selected = isSelected, @@ -1131,6 +1137,7 @@ private fun navigationItemLabelWithMenu( text: String, onMenuClick: () -> Unit, isHovered: Boolean, + bookmarkCount: Int = 0, ) { val fontScale = LocalFontScale.current Row( @@ -1161,6 +1168,25 @@ private fun navigationItemLabelWithMenu( ) } } + } else if (bookmarkCount > 0) { + // Bookmark badge — visible when not hovered, hidden when menu button appears + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = Spacing.extraSmall), + ) { + Icon( + imageVector = Icons.Default.Bookmark, + contentDescription = null, + modifier = Modifier.size((10 * fontScale).dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), + ) + Text( + text = "$bookmarkCount", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), + ) + } } } } diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index c39dc37aa..66d8124f8 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -31,6 +31,7 @@ menu.view.appearance.system=System menu.view.appearance.light=Light menu.view.appearance.dark=Dark menu.view.fullscreen=Enter Full Screen +menu.view.bookmarks=View Bookmarks menu.terminal=Terminal menu.terminal.new=New Terminal menu.help=Help @@ -321,8 +322,6 @@ settings.skills.add.folder=Add Folder settings.skills.add.folder.name=Folder name settings.advanced=Advanced settings.about=About -settings.model=Model -settings.provider=Provider settings.theme=Theme settings.save=Save settings.cancel=Cancel @@ -590,6 +589,7 @@ shortcut.stop.ai.response=Stop AI Response shortcut.quit.application=Quit Application shortcut.navigate.to.sessions=Navigate to Sessions shortcut.navigate.to.projects=Navigate to Projects +shortcut.navigate.to.bookmarks=Navigate to Bookmarks shortcut.enter.fullscreen=Enter Full Screen shortcut.close.search=Close Search shortcut.next.search.result=Next Search Result @@ -842,12 +842,28 @@ message.ai.try.again.confirm.cancel=Cancel message.ai.try.again.confirm.confirm=Regenerate message.ai.export.pdf=Export as PDF message.ai.export.pdf.dialog.title=Save AI Response as PDF +message.bookmark=Bookmark message +message.bookmark.remove=Remove bookmark +message.bookmark.description=Bookmark this message message.edited.indicator=(edited) message.date.today=Today message.date.yesterday=Yesterday message.token.usage={0} tokens · {1} in / {2} out message.token.usage.tooltip=Tokens used by this response.\nInput: your prompt + context sent to the AI.\nOutput: the AI-generated reply. +# Bookmarks +chat.bookmarks.button.tooltip=Bookmarks +chat.bookmarks.popover.title=Pinned messages +chat.bookmarks.popover.empty.title=No bookmarks yet +chat.bookmarks.popover.empty.hint=Pin any message using the bookmark icon below it +bookmarks.title=Bookmarks +bookmarks.empty.title=No bookmarks yet +bookmarks.empty.hint=Bookmark any message using the pin icon below it +bookmarks.session.untitled=Untitled conversation +bookmarks.message.count={0} pinned +bookmarks.role.user=You +bookmarks.role.ai=AI + # Tool call display tool.call.header.running=Running tool... tool.call.header.done=Used {0} tool(s) @@ -1462,11 +1478,6 @@ skills.agentic.skills.available={0} skills available as agent context skills.agentic.no.skills.hint=No skills yet — add skills in Settings → Skills to give the agent specialized capabilities skills.agentic.model.default=Default model -# File / folder chooser -file.chooser.folder.select=Select -file.chooser.folder.hint=Double-click a folder to enter it. When you’re in the folder you want to index, click “Select”. -file.chooser.file.select=Select - # Onboarding Wizard onboarding.title=Welcome to Askimo onboarding.step.indicator=Step {0} of {1} diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index 97ad100d2..10fbf2727 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -321,8 +321,6 @@ settings.skills.add.folder=Ordner hinzufügen settings.skills.add.folder.name=Ordnername settings.advanced=Erweitert settings.about=Über -settings.model=Modell -settings.provider=Anbieter settings.theme=Design settings.save=Speichern settings.cancel=Abbrechen @@ -567,6 +565,7 @@ shortcut.stop.ai.response=KI-Antwort stoppen shortcut.quit.application=Anwendung beenden shortcut.navigate.to.sessions=Zu Sitzungen navigieren shortcut.navigate.to.projects=Zu Projekten navigieren +shortcut.navigate.to.bookmarks=Zu Lesezeichen navigieren shortcut.enter.fullscreen=Vollbildmodus aktivieren shortcut.close.search=Suche schließen shortcut.next.search.result=Nächstes Suchergebnis @@ -1443,11 +1442,6 @@ skills.agentic.skills.available={0} Fähigkeiten als Agent-Kontext verfügbar skills.agentic.no.skills.hint=Noch keine Fähigkeiten — füge in Einstellungen → Fähigkeiten Fähigkeiten hinzu, um dem Agenten spezielle Funktionen zu geben skills.agentic.model.default=Standardmodell -# File / folder chooser -file.chooser.folder.select=Auswählen -file.chooser.folder.hint=Doppelklicken Sie auf einen Ordner, um ihn zu öffnen. Wenn Sie sich in dem Ordner befinden, den Sie indizieren möchten, klicken Sie auf „Auswählen“. -file.chooser.file.select=Auswählen - # Onboarding Wizard onboarding.title=Willkommen bei Askimo onboarding.step.indicator=Schritt {0} von {1} @@ -1556,3 +1550,20 @@ settings.web_search.test.fail=\u274c {0} # Chat input placeholder hints (stub — pending translation) chat.input.placeholder.hint.search=Try: "search the web for…" or paste a URL to read any page chat.input.placeholder.hint.attach=Attach files with {0}+A, or drag and drop them here + +# Bookmarks +menu.view.bookmarks=Lesezeichen anzeigen +message.bookmark=Nachricht als Lesezeichen speichern +message.bookmark.remove=Lesezeichen entfernen +message.bookmark.description=Diese Nachricht als Lesezeichen speichern +chat.bookmarks.button.tooltip=Lesezeichen +chat.bookmarks.popover.title=Angeheftete Nachrichten +chat.bookmarks.popover.empty.title=Noch keine Lesezeichen +chat.bookmarks.popover.empty.hint=Hefte eine Nachricht mit dem Lesezeichen-Symbol darunter an +bookmarks.title=Lesezeichen +bookmarks.empty.title=Noch keine Lesezeichen +bookmarks.empty.hint=Speichere Nachrichten als Lesezeichen über das Heft-Symbol darunter +bookmarks.session.untitled=Unbenanntes Gespräch +bookmarks.message.count={0} angeheftet +bookmarks.role.user=Du +bookmarks.role.ai=KI diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index 47d124097..1b72a6d0a 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -322,8 +322,6 @@ settings.skills.add.folder=Añadir carpeta settings.skills.add.folder.name=Nombre de carpeta settings.advanced=Avanzado settings.about=Acerca de -settings.model=Modelo -settings.provider=Proveedor settings.theme=Tema settings.save=Guardar settings.cancel=Cancelar @@ -567,6 +565,7 @@ shortcut.stop.ai.response=Detener respuesta de la IA shortcut.quit.application=Salir de la aplicación shortcut.navigate.to.sessions=Ir a sesiones shortcut.navigate.to.projects=Ir a Proyectos +shortcut.navigate.to.bookmarks=Navegar a marcadores shortcut.enter.fullscreen=Entrar en pantalla completa shortcut.close.search=Cerrar búsqueda shortcut.next.search.result=Siguiente resultado de búsqueda @@ -1439,11 +1438,6 @@ skills.agentic.skills.available={0} habilidades disponibles como contexto del ag skills.agentic.no.skills.hint=Todavía no hay habilidades — añade habilidades en Configuración → Habilidades para darle al agente capacidades especializadas skills.agentic.model.default=Modelo predeterminado -# File / folder chooser -file.chooser.folder.select=Seleccionar -file.chooser.folder.hint=Haz doble clic en una carpeta para entrar en ella. Cuando estés en la carpeta que quieres indexar, haz clic en “Seleccionar”. -file.chooser.file.select=Seleccionar - # Onboarding Wizard onboarding.title=Bienvenido a Askimo onboarding.step.indicator=Paso {0} de {1} @@ -1552,3 +1546,20 @@ settings.web_search.test.fail=\u274c {0} # Chat input placeholder hints (stub — pending translation) chat.input.placeholder.hint.search=Try: "search the web for…" or paste a URL to read any page chat.input.placeholder.hint.attach=Attach files with {0}+A, or drag and drop them here + +# Bookmarks +menu.view.bookmarks=Ver marcadores +message.bookmark=Marcar mensaje +message.bookmark.remove=Quitar marcador +message.bookmark.description=Marcar este mensaje +chat.bookmarks.button.tooltip=Marcadores +chat.bookmarks.popover.title=Mensajes fijados +chat.bookmarks.popover.empty.title=Aún no hay marcadores +chat.bookmarks.popover.empty.hint=Fija cualquier mensaje con el icono de marcador debajo de él +bookmarks.title=Marcadores +bookmarks.empty.title=Aún no hay marcadores +bookmarks.empty.hint=Marca mensajes con el icono de anclaje debajo de ellos +bookmarks.session.untitled=Conversación sin título +bookmarks.message.count={0} fijados +bookmarks.role.user=Tú +bookmarks.role.ai=IA diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index 1275bbe7a..dc12a5c66 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -322,8 +322,6 @@ settings.skills.add.folder=Ajouter un dossier settings.skills.add.folder.name=Nom du dossier settings.advanced=Avancé settings.about=À propos -settings.model=Modèle -settings.provider=Fournisseur settings.theme=Thème settings.save=Enregistrer settings.cancel=Annuler @@ -567,6 +565,7 @@ shortcut.stop.ai.response=Arrêter la réponse de l’IA shortcut.quit.application=Quitter l’application shortcut.navigate.to.sessions=Accéder aux sessions shortcut.navigate.to.projects=Accéder aux projets +shortcut.navigate.to.bookmarks=Accéder aux favoris shortcut.enter.fullscreen=Entrer en plein écran shortcut.close.search=Fermer la recherche shortcut.next.search.result=Résultat de recherche suivant @@ -1440,11 +1439,6 @@ skills.agentic.skills.available={0} compétences disponibles comme contexte de l skills.agentic.no.skills.hint=Il n’y a encore aucune compétence — ajoutez des compétences dans Paramètres → Compétences pour donner à l’agent des capacités spécialisées skills.agentic.model.default=Modèle par défaut -# File / folder chooser -file.chooser.folder.select=Sélectionner -file.chooser.folder.hint=Double-cliquez sur un dossier pour l’ouvrir. Lorsque vous êtes dans le dossier que vous souhaitez indexer, cliquez sur « Sélectionner ». -file.chooser.file.select=Sélectionner - # Onboarding Wizard onboarding.title=Bienvenue sur Askimo onboarding.step.indicator=Étape {0} sur {1} @@ -1554,3 +1548,20 @@ settings.web_search.test.fail=\u274c {0} # Chat input placeholder hints (stub — pending translation) chat.input.placeholder.hint.search=Try: "search the web for…" or paste a URL to read any page chat.input.placeholder.hint.attach=Attach files with {0}+A, or drag and drop them here + +# Bookmarks +menu.view.bookmarks=Voir les signets +message.bookmark=Mettre en signet +message.bookmark.remove=Supprimer le signet +message.bookmark.description=Mettre ce message en signet +chat.bookmarks.button.tooltip=Signets +chat.bookmarks.popover.title=Messages épinglés +chat.bookmarks.popover.empty.title=Aucun signet pour l’instant +chat.bookmarks.popover.empty.hint=Épingle un message avec l’icône signet en dessous +bookmarks.title=Signets +bookmarks.empty.title=Aucun signet pour l’instant +bookmarks.empty.hint=Ajoute un signet via l’icône d’épingle sous chaque message +bookmarks.session.untitled=Conversation sans titre +bookmarks.message.count={0} épinglés +bookmarks.role.user=Vous +bookmarks.role.ai=IA diff --git a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties index ad9a47578..8b75aece8 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -321,8 +321,6 @@ settings.skills.add.folder=フォルダーを追加 settings.skills.add.folder.name=フォルダー名 settings.advanced=詳細設定 settings.about=情報 -settings.model=モデル -settings.provider=プロバイダー settings.theme=テーマ settings.save=保存 settings.cancel=キャンセル @@ -566,6 +564,7 @@ shortcut.stop.ai.response=AI の応答を停止 shortcut.quit.application=アプリケーションを終了 shortcut.navigate.to.sessions=セッションに移動 shortcut.navigate.to.projects=プロジェクトへ移動 +shortcut.navigate.to.bookmarks=ブックマークへ移動 shortcut.enter.fullscreen=フルスクリーンにする shortcut.close.search=検索を閉じる shortcut.next.search.result=次の検索結果 @@ -1439,11 +1438,6 @@ skills.agentic.skills.available={0} 件のスキルをエージェントのコ skills.agentic.no.skills.hint=まだスキルがありません — 設定 → スキル でスキルを追加すると、エージェントに特化した機能を与えられます skills.agentic.model.default=既定のモデル -# File / folder chooser -file.chooser.folder.select=選択 -file.chooser.folder.hint=フォルダーをダブルクリックすると、そのフォルダーに移動します。インデックスを作成したいフォルダーを開いたら、「選択」をクリックしてください。 -file.chooser.file.select=選択 - # Onboarding Wizard onboarding.title=Askimoへようこそ onboarding.step.indicator=ステップ {0} / {1} @@ -1553,3 +1547,20 @@ settings.web_search.test.fail=\u274c {0} # Chat input placeholder hints (stub — pending translation) chat.input.placeholder.hint.search=Try: "search the web for…" or paste a URL to read any page chat.input.placeholder.hint.attach=Attach files with {0}+A, or drag and drop them here +# Bookmarks +menu.view.bookmarks=ブックマークを表示 +message.bookmark=メッセージをブックマーク +message.bookmark.remove=ブックマークを解除 +message.bookmark.description=このメッセージをブックマークする +chat.bookmarks.button.tooltip=ブックマーク +chat.bookmarks.popover.title=ピン留めしたメッセージ +chat.bookmarks.popover.empty.title=ブックマークはまだありません +chat.bookmarks.popover.empty.hint=メッセージ下のブックマークアイコンでピン留めできます +bookmarks.title=ブックマーク +bookmarks.empty.title=ブックマークはまだありません +bookmarks.empty.hint=メッセージ下のピンアイコンでブックマークできます +bookmarks.session.untitled=タイトルなしの会話 +bookmarks.message.count={0}件ピン留め +bookmarks.role.user=あなた +bookmarks.role.ai=AI +EOF#cat >> /Users/hainguyen/Projects/github/huynguyen-askimo/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties << 'EOF' \ No newline at end of file diff --git a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties index 38fe952d0..daaaf463f 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -322,8 +322,6 @@ settings.skills.add.folder=폴더 추가 settings.skills.add.folder.name=폴더 이름 settings.advanced=고급 settings.about=정보 -settings.model=모델 -settings.provider=제공자 settings.theme=테마 settings.save=저장 settings.cancel=취소 @@ -567,6 +565,7 @@ shortcut.stop.ai.response=AI 응답 중지 shortcut.quit.application=애플리케이션 종료 shortcut.navigate.to.sessions=세션으로 이동 shortcut.navigate.to.projects=프로젝트로 이동 +shortcut.navigate.to.bookmarks=책갈피로 이동 shortcut.enter.fullscreen=전체 화면으로 전환 shortcut.close.search=검색 닫기 shortcut.next.search.result=다음 검색 결과 @@ -1439,11 +1438,6 @@ skills.agentic.skills.available={0}개의 스킬을 에이전트 컨텍스트로 skills.agentic.no.skills.hint=아직 스킬이 없습니다 — 설정 → 스킬에서 스킬을 추가하여 에이전트에 특화된 기능을 제공하세요 skills.agentic.model.default=기본 모델 -# File / folder chooser -file.chooser.folder.select=선택 -file.chooser.folder.hint=폴더를 두 번 클릭하여 들어가세요. 인덱싱하려는 폴더로 이동한 다음 “선택”을 클릭하세요. -file.chooser.file.select=선택 - # Onboarding Wizard onboarding.title=Askimo에 오신 것을 환영합니다 onboarding.step.indicator={1}단계 중 {0}단계 @@ -1553,3 +1547,19 @@ settings.web_search.test.fail=\u274c {0} # Chat input placeholder hints (stub — pending translation) chat.input.placeholder.hint.search=Try: "search the web for…" or paste a URL to read any page chat.input.placeholder.hint.attach=Attach files with {0}+A, or drag and drop them here +# Bookmarks +menu.view.bookmarks=북마크 보기 +message.bookmark=메시지 북마크 +message.bookmark.remove=북마크 제거 +message.bookmark.description=이 메시지를 북마크합니다 +chat.bookmarks.button.tooltip=북마크 +chat.bookmarks.popover.title=고정된 메시지 +chat.bookmarks.popover.empty.title=아직 북마크가 없습니다 +chat.bookmarks.popover.empty.hint=메시지 아래의 북마크 아이콘으로 고정하세요 +bookmarks.title=북마크 +bookmarks.empty.title=아직 북마크가 없습니다 +bookmarks.empty.hint=메시지 아래의 핀 아이콘으로 북마크하세요 +bookmarks.session.untitled=제목 없는 대화 +bookmarks.message.count={0}개 고정됨 +bookmarks.role.user=나 +bookmarks.role.ai=AI diff --git a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties index 8103a94d2..133a2b2d0 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -321,8 +321,6 @@ settings.skills.add.folder=Adicionar pasta settings.skills.add.folder.name=Nome da pasta settings.advanced=Avançado settings.about=Sobre -settings.model=Modelo -settings.provider=Provedor settings.theme=Tema settings.save=Salvar settings.cancel=Cancelar @@ -568,6 +566,7 @@ shortcut.stop.ai.response=Parar resposta da IA shortcut.quit.application=Sair da aplicação shortcut.navigate.to.sessions=Navegar para sessões shortcut.navigate.to.projects=Ir para Projetos +shortcut.navigate.to.bookmarks=Navegar para favoritos shortcut.enter.fullscreen=Entrar em tela cheia shortcut.close.search=Fechar pesquisa shortcut.next.search.result=Próximo resultado da pesquisa @@ -1441,11 +1440,6 @@ skills.agentic.skills.available={0} habilidades disponíveis como contexto do ag skills.agentic.no.skills.hint=Ainda não há habilidades — adicione habilidades em Configurações → Habilidades para dar ao agente capacidades especializadas skills.agentic.model.default=Modelo padrão -# File / folder chooser -file.chooser.folder.select=Selecionar -file.chooser.folder.hint=Clique duas vezes em uma pasta para entrar nela. Quando estiver na pasta que deseja indexar, clique em “Selecionar”. -file.chooser.file.select=Selecionar - # Onboarding Wizard onboarding.title=Bem-vindo ao Askimo onboarding.step.indicator=Etapa {0} de {1} @@ -1554,3 +1548,20 @@ settings.web_search.test.fail=\u274c {0} # Chat input placeholder hints (stub — pending translation) chat.input.placeholder.hint.search=Try: "search the web for…" or paste a URL to read any page chat.input.placeholder.hint.attach=Attach files with {0}+A, or drag and drop them here + +# Bookmarks +menu.view.bookmarks=Ver favoritos +message.bookmark=Favoritar mensagem +message.bookmark.remove=Remover favorito +message.bookmark.description=Favoritar esta mensagem +chat.bookmarks.button.tooltip=Favoritos +chat.bookmarks.popover.title=Mensagens fixadas +chat.bookmarks.popover.empty.title=Nenhum favorito ainda +chat.bookmarks.popover.empty.hint=Fixe qualquer mensagem com o ícone de favorito abaixo dela +bookmarks.title=Favoritos +bookmarks.empty.title=Nenhum favorito ainda +bookmarks.empty.hint=Favorite mensagens com o ícone de fixar abaixo delas +bookmarks.session.untitled=Conversa sem título +bookmarks.message.count={0} fixados +bookmarks.role.user=Você +bookmarks.role.ai=IA diff --git a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties index 701e23127..78549c368 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -331,8 +331,6 @@ settings.skills.add.folder=Thêm thư mục settings.skills.add.folder.name=Tên thư mục settings.advanced=Nâng cao settings.about=Giới thiệu -settings.model=Mô hình -settings.provider=Nhà cung cấp settings.theme=Chủ đề settings.save=Lưu settings.cancel=Hủy @@ -574,6 +572,7 @@ shortcut.stop.ai.response=Dừng phản hồi AI shortcut.quit.application=Thoát ứng dụng shortcut.navigate.to.sessions=Đi tới phiên shortcut.navigate.to.projects=Đi tới Dự án +shortcut.navigate.to.bookmarks=Điều hướng đến dấu trang shortcut.enter.fullscreen=Chuyển sang toàn màn hình shortcut.close.search=Đóng tìm kiếm shortcut.next.search.result=Kết quả tìm kiếm tiếp theo @@ -1438,11 +1437,6 @@ skills.agentic.skills.available=Có {0} kỹ năng khả dụng làm ngữ cản skills.agentic.no.skills.hint=Chưa có kỹ năng nào — hãy thêm kỹ năng trong Cài đặt → Kỹ năng để cung cấp cho tác nhân các khả năng chuyên biệt skills.agentic.model.default=Mô hình mặc định -# File / folder chooser -file.chooser.folder.select=Chọn -file.chooser.folder.hint=Nhấp đúp vào một thư mục để mở. Khi bạn đang ở trong thư mục muốn lập chỉ mục, hãy nhấp vào “Chọn”. -file.chooser.file.select=Chọn - # Onboarding Wizard onboarding.title=Chào mừng đến với Askimo onboarding.step.indicator=Bước {0} trong {1} @@ -1551,3 +1545,19 @@ settings.web_search.test.fail=\u274c {0} # Chat input placeholder hints (stub — pending translation) chat.input.placeholder.hint.search=Try: "search the web for…" or paste a URL to read any page chat.input.placeholder.hint.attach=Attach files with {0}+A, or drag and drop them here +# Bookmarks +menu.view.bookmarks=Xem dấu trang +message.bookmark=Đánh dấu tin nhắn +message.bookmark.remove=Xóa dấu trang +message.bookmark.description=Đánh dấu tin nhắn này +chat.bookmarks.button.tooltip=Dấu trang +chat.bookmarks.popover.title=Tin nhắn đã ghim +chat.bookmarks.popover.empty.title=Chưa có dấu trang +chat.bookmarks.popover.empty.hint=Ghim tin nhắn bằng biểu tượng dấu trang bên dưới +bookmarks.title=Dấu trang +bookmarks.empty.title=Chưa có dấu trang +bookmarks.empty.hint=Đánh dấu tin nhắn bằng biểu tượng ghim bên dưới +bookmarks.session.untitled=Cuộc trò chuyện chưa đặt tên +bookmarks.message.count={0} đã ghim +bookmarks.role.user=Bạn +bookmarks.role.ai=AI diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties index 4ecb6237f..c48ff8ebb 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -321,8 +321,6 @@ settings.skills.add.folder=添加文件夹 settings.skills.add.folder.name=文件夹名称 settings.advanced=高级 settings.about=关于 -settings.model=模型 -settings.provider=提供商 settings.theme=主题 settings.save=保存 settings.cancel=取消 @@ -566,6 +564,7 @@ shortcut.stop.ai.response=停止 AI 回复 shortcut.quit.application=退出应用 shortcut.navigate.to.sessions=前往会话 shortcut.navigate.to.projects=转到项目 +shortcut.navigate.to.bookmarks=导航到书签 shortcut.enter.fullscreen=进入全屏 shortcut.close.search=关闭搜索 shortcut.next.search.result=下一个搜索结果 @@ -1440,11 +1439,6 @@ skills.agentic.skills.available={0} 个技能可作为代理上下文使用 skills.agentic.no.skills.hint=还没有技能 — 前往 设置 → 技能 添加技能,让代理具备专门能力 skills.agentic.model.default=默认模型 -# File / folder chooser -file.chooser.folder.select=选择 -file.chooser.folder.hint=双击文件夹即可进入。当你进入想要建立索引的文件夹后,点击“选择”。 -file.chooser.file.select=选择 - # Onboarding Wizard onboarding.title=欢迎使用 Askimo onboarding.step.indicator=第 {0} 步,共 {1} 步 @@ -1553,3 +1547,20 @@ settings.web_search.test.fail=\u274c {0} # Chat input placeholder hints (stub — pending translation) chat.input.placeholder.hint.search=Try: "search the web for…" or paste a URL to read any page chat.input.placeholder.hint.attach=Attach files with {0}+A, or drag and drop them here + +# Bookmarks +menu.view.bookmarks=查看书签 +message.bookmark=收藏消息 +message.bookmark.remove=取消收藏 +message.bookmark.description=收藏此消息 +chat.bookmarks.button.tooltip=书签 +chat.bookmarks.popover.title=已固定的消息 +chat.bookmarks.popover.empty.title=暂无书签 +chat.bookmarks.popover.empty.hint=点击消息下方的书签图标进行固定 +bookmarks.title=书签 +bookmarks.empty.title=暂无书签 +bookmarks.empty.hint=点击消息下方的固定图标添加书签 +bookmarks.session.untitled=未命名对话 +bookmarks.message.count=已固定 {0} 条 +bookmarks.role.user=你 +bookmarks.role.ai=AI diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties index 1bab4bbc4..cc9f6f99a 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -321,8 +321,6 @@ settings.skills.add.folder=新增資料夾 settings.skills.add.folder.name=資料夾名稱 settings.advanced=進階 settings.about=關於 -settings.model=模型 -settings.provider=提供者 settings.theme=主題 settings.save=儲存 settings.cancel=取消 @@ -566,6 +564,7 @@ shortcut.stop.ai.response=停止 AI 回應 shortcut.quit.application=結束應用程式 shortcut.navigate.to.sessions=前往工作階段 shortcut.navigate.to.projects=前往專案 +shortcut.navigate.to.bookmarks=導航至書籤 shortcut.enter.fullscreen=進入全螢幕 shortcut.close.search=關閉搜尋 shortcut.next.search.result=下一個搜尋結果 @@ -1441,11 +1440,6 @@ skills.agentic.skills.available={0} 個技能可作為代理程式上下文使 skills.agentic.no.skills.hint=還沒有技能 — 請在 設定 → 技能 新增技能,讓代理程式具備專門能力 skills.agentic.model.default=預設模型 -# File / folder chooser -file.chooser.folder.select=選取 -file.chooser.folder.hint=按兩下資料夾即可進入。當你進入想要建立索引的資料夾後,點擊「選取」。 -file.chooser.file.select=選取 - # Onboarding Wizard onboarding.title=歡迎使用 Askimo onboarding.step.indicator=第 {0} 步,共 {1} 步 @@ -1555,3 +1549,19 @@ settings.web_search.test.fail=\u274c {0} # Chat input placeholder hints (stub — pending translation) chat.input.placeholder.hint.search=Try: "search the web for…" or paste a URL to read any page chat.input.placeholder.hint.attach=Attach files with {0}+A, or drag and drop them here +# Bookmarks +menu.view.bookmarks=查看書籤 +message.bookmark=收藏訊息 +message.bookmark.remove=取消收藏 +message.bookmark.description=收藏此訊息 +chat.bookmarks.button.tooltip=書籤 +chat.bookmarks.popover.title=已固定的訊息 +chat.bookmarks.popover.empty.title=尚無書籤 +chat.bookmarks.popover.empty.hint=點擊訊息下方的書籤圖示進行固定 +bookmarks.title=書籤 +bookmarks.empty.title=尚無書籤 +bookmarks.empty.hint=點擊訊息下方的固定圖示新增書籤 +bookmarks.session.untitled=未命名對話 +bookmarks.message.count=已固定 {0} 則 +bookmarks.role.user=你 +bookmarks.role.ai=AI diff --git a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt index 20a680cc5..ccf44d485 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt @@ -99,6 +99,8 @@ 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.BookmarksViewModel +import io.askimo.ui.bookmarks.bookmarksView import io.askimo.ui.chat.ChatViewModel import io.askimo.ui.chat.chatView import io.askimo.ui.common.components.dangerButton @@ -473,6 +475,8 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = val appContext = remember { koin.get() } val chatSessionService = remember { koin.get() } + val bookmarksViewModel = remember { BookmarksViewModel(chatSessionService, scope) } + val sessionManager = remember { koin.get() } val sessionsViewModel = remember { koin.get { @@ -724,6 +728,7 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = isSkillsVisible = showSkillsInSidebar, isProjectsVisible = showProjectsInSidebar, onShowSystemDiagnostics = { showSystemDiagnosticsDialog = true }, + onNavigateToBookmarks = { currentView = View.BOOKMARKS }, ) } } @@ -1141,6 +1146,7 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = userAvatarPath = userProfile?.preferences?.get("avatarPath"), userProfile = userProfile, discoverViewModel = discoverViewModel, + bookmarksViewModel = bookmarksViewModel, showTokenUsageCard = showTokenUsageCard, onToggleTokenUsageCard = { enabled -> showTokenUsageCard = enabled @@ -1973,6 +1979,7 @@ fun mainContent( showTokenUsageCard: Boolean = true, onToggleTokenUsageCard: (Boolean) -> Unit = {}, onOpenSystemDiagnostics: () -> Unit = {}, + bookmarksViewModel: BookmarksViewModel? = null, ) { val discoverMetrics by appContext.telemetry.metricsFlow.collectAsState() Box( @@ -2176,6 +2183,20 @@ fun mainContent( View.SKILLS -> skillsView( onNavigateToSkillsSettings = onNavigateToSkillsSettings, ) + + View.BOOKMARKS -> { + val vm = bookmarksViewModel + if (vm != null) { + LaunchedEffect(Unit) { vm.load() } + bookmarksView( + viewModel = vm, + onNavigateToSession = { sessionId -> + onResumeSession(sessionId) + }, + modifier = Modifier.fillMaxSize(), + ) + } + } } } } diff --git a/desktop/src/main/kotlin/io/askimo/desktop/View.kt b/desktop/src/main/kotlin/io/askimo/desktop/View.kt index 9ed1796b0..f43961c10 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/View.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/View.kt @@ -16,4 +16,5 @@ enum class View { PLAN_EDITOR, SKILLS, SETTINGS, + BOOKMARKS, } diff --git a/shared/src/main/kotlin/io/askimo/core/chat/domain/ChatMessage.kt b/shared/src/main/kotlin/io/askimo/core/chat/domain/ChatMessage.kt index dd1a6b4eb..fa8fa7e96 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/domain/ChatMessage.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/domain/ChatMessage.kt @@ -25,6 +25,7 @@ data class ChatMessage( val outputTokens: Int? = null, val totalTokens: Int? = null, val durationMs: Long? = null, + val isBookmarked: Boolean = false, ) /** @@ -52,6 +53,7 @@ object ChatMessagesTable : Table("chat_messages") { val outputTokens = integer("output_tokens").nullable() val totalTokens = integer("total_tokens").nullable() val durationMs = long("duration_ms").nullable() + val isBookmarked = integer("is_bookmarked").default(0) val syncedAt = varchar("synced_at", 32).nullable() diff --git a/shared/src/main/kotlin/io/askimo/core/chat/dto/ChatMessageDTO.kt b/shared/src/main/kotlin/io/askimo/core/chat/dto/ChatMessageDTO.kt index b5e9d6b29..74f5560cd 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/dto/ChatMessageDTO.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/dto/ChatMessageDTO.kt @@ -24,4 +24,5 @@ data class ChatMessageDTO( val outputTokens: Int? = null, val totalTokens: Int? = null, val durationMs: Long? = null, + val isBookmarked: Boolean = false, ) diff --git a/shared/src/main/kotlin/io/askimo/core/chat/mapper/ChatMessageMapper.kt b/shared/src/main/kotlin/io/askimo/core/chat/mapper/ChatMessageMapper.kt index 4deda758a..789f2f394 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/mapper/ChatMessageMapper.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/mapper/ChatMessageMapper.kt @@ -33,6 +33,7 @@ object ChatMessageMapper { outputTokens = this.outputTokens, totalTokens = this.totalTokens, durationMs = this.durationMs, + isBookmarked = this.isBookmarked, ) /** diff --git a/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatMessageRepository.kt b/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatMessageRepository.kt index 43529bb28..b48fd7ab7 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatMessageRepository.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatMessageRepository.kt @@ -7,6 +7,7 @@ package io.askimo.core.chat.repository import io.askimo.core.chat.domain.ChatMessage import io.askimo.core.chat.domain.ChatMessageAttachmentsTable import io.askimo.core.chat.domain.ChatMessagesTable +import io.askimo.core.chat.domain.ChatSession import io.askimo.core.chat.domain.ChatSessionsTable import io.askimo.core.chat.domain.FileAttachment import io.askimo.core.context.MessageRole @@ -15,6 +16,7 @@ import io.askimo.core.db.DatabaseManager import io.askimo.core.event.EventBus import io.askimo.core.event.internal.PushDataToServerEvent import io.askimo.core.logging.logger +import org.jetbrains.exposed.v1.core.JoinType import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.and @@ -68,6 +70,7 @@ private fun ResultRow.toChatMessage(): ChatMessage = ChatMessage( outputTokens = this[ChatMessagesTable.outputTokens], totalTokens = this[ChatMessagesTable.totalTokens], durationMs = this[ChatMessagesTable.durationMs], + isBookmarked = this[ChatMessagesTable.isBookmarked] == 1, ) /** @@ -554,4 +557,93 @@ class ChatMessageRepository internal constructor( .limit(limit) .map { it.toChatMessage() } } + + /** + * Toggle the bookmark state of a message. + * @return true if the message is now bookmarked, false if it is now un-bookmarked. + */ + fun toggleBookmark(messageId: String): Boolean = transaction(database) { + val current = ChatMessagesTable + .selectAll() + .where { ChatMessagesTable.id eq messageId } + .firstOrNull() + ?.get(ChatMessagesTable.isBookmarked) ?: 0 + + val next = if (current == 1) 0 else 1 + ChatMessagesTable.update({ ChatMessagesTable.id eq messageId }) { + it[isBookmarked] = next + } + next == 1 + } + + /** + * Return all bookmarked messages for a single session, ordered by creation time. + */ + fun getBookmarkedMessages(sessionId: String): List = transaction(database) { + ChatMessagesTable + .selectAll() + .where { + (ChatMessagesTable.sessionId eq sessionId) and + (ChatMessagesTable.isBookmarked eq 1) + } + .orderBy(ChatMessagesTable.createdAt, SortOrder.ASC) + .map { it.toChatMessage() } + } + + /** + * Return all bookmarked messages across every session, ordered newest-first. + * Used by the global Bookmarks view. + */ + fun getAllBookmarkedMessages(): List = transaction(database) { + ChatMessagesTable + .selectAll() + .where { ChatMessagesTable.isBookmarked eq 1 } + .orderBy(ChatMessagesTable.createdAt, SortOrder.DESC) + .map { it.toChatMessage() } + } + + /** + * Return all bookmarked messages joined with their parent sessions in a **single** query, + * ordered by session.updated_at DESC then message.created_at ASC. + * + * This avoids the two-round-trip pattern of fetching messages first and then fetching + * sessions by ID. The caller can group the flat list in-memory while retaining the + * DB-provided ordering. + */ + fun getAllBookmarkedWithSessions(): List> = transaction(database) { + ChatMessagesTable + .join(ChatSessionsTable, JoinType.INNER, ChatMessagesTable.sessionId, ChatSessionsTable.id) + .selectAll() + .where { ChatMessagesTable.isBookmarked eq 1 } + .orderBy( + ChatSessionsTable.updatedAt to SortOrder.DESC, + ChatMessagesTable.createdAt to SortOrder.ASC, + ) + .map { row -> + val message = row.toChatMessage() + val session = ChatSession( + id = row[ChatSessionsTable.id], + title = row[ChatSessionsTable.title], + createdAt = row[ChatSessionsTable.createdAt], + updatedAt = row[ChatSessionsTable.updatedAt], + projectId = row[ChatSessionsTable.projectId], + directiveId = row[ChatSessionsTable.directiveId], + isStarred = row[ChatSessionsTable.isStarred] == 1, + ) + message to session + } + } + + /** + * Return a map of sessionId → bookmark count for every session that has at least one + * bookmarked message. Uses a single query + in-memory grouping. + * Used by the sidebar to render the 🔖 N badge efficiently. + */ + fun getBookmarkCountsBySession(): Map = transaction(database) { + ChatMessagesTable + .selectAll() + .where { ChatMessagesTable.isBookmarked eq 1 } + .groupBy { it[ChatMessagesTable.sessionId] } + .mapValues { it.value.size } + } } diff --git a/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt b/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt index fc46ca472..f7ac7c098 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt @@ -13,6 +13,7 @@ import io.askimo.core.chat.domain.ChatMessage import io.askimo.core.chat.domain.ChatSession import io.askimo.core.chat.domain.Project import io.askimo.core.chat.dto.ChatMessageDTO +import io.askimo.core.chat.mapper.ChatMessageMapper.toDTO import io.askimo.core.chat.mapper.ChatMessageMapper.toDTOs import io.askimo.core.chat.mapper.ChatMessageMapper.toDomain import io.askimo.core.chat.repository.ChatMessageRepository @@ -61,6 +62,14 @@ data class SessionChatContext( val memory: TokenAwareSummarizingMemory, ) +/** + * A single conversation's worth of bookmarked messages, used by the global Bookmarks view. + */ +data class BookmarkGroup( + val session: ChatSession, + val messages: List, +) + /** * Result of resuming a chat session. */ @@ -727,11 +736,45 @@ class ChatSessionService( } /** - * Constructs a formatted message with both file attachments and extracted URL contents. - * - * @param userMessage The original user message - * @return Formatted message with attachments and URL contents appended + * Toggle bookmark state for a message. + * @return true if the message is now bookmarked, false if un-bookmarked. */ + fun toggleBookmark(messageId: String): Boolean = messageRepository.toggleBookmark(messageId) + + /** + * Return all bookmarked messages for a given session, ordered by creation time. + */ + fun getBookmarkedMessages(sessionId: String): List = messageRepository.getBookmarkedMessages(sessionId).toDTOs() + + /** + * Return all bookmarked messages across all sessions, ordered newest-first. + * Used by the global Bookmarks view. + */ + fun getAllBookmarkedMessages(): List = messageRepository.getAllBookmarkedMessages().toDTOs() + + /** + * Return a map of sessionId → bookmark count for sessions with at least one bookmark. + * Used by the sidebar to show 🔖 N badges without per-row queries. + */ + fun getBookmarkCountsBySession(): Map = messageRepository.getBookmarkCountsBySession() + + /** + * Return all bookmarked messages across all sessions grouped by their conversation, + * ordered by session's most recent activity descending. + * Used by the global Bookmarks view. + */ + fun getAllBookmarkGroups(): List { + val rows = messageRepository.getAllBookmarkedWithSessions() + if (rows.isEmpty()) return emptyList() + + // groupBy on a LinkedHashMap preserves the insertion order from the DB result, + // so sessions remain sorted by updatedAt DESC and messages within each group + // remain sorted by createdAt ASC — exactly the DB ORDER BY clause. + return rows + .groupBy({ it.second }, { it.first }) + .map { (session, messages) -> BookmarkGroup(session, messages.map { it.toDTO() }) } + } + private fun constructMessageWithAttachmentsAndUrls( userMessage: ChatMessageDTO, ): String = buildString { diff --git a/shared/src/main/kotlin/io/askimo/core/db/DatabaseManager.kt b/shared/src/main/kotlin/io/askimo/core/db/DatabaseManager.kt index 31b98841d..1c3ae0252 100644 --- a/shared/src/main/kotlin/io/askimo/core/db/DatabaseManager.kt +++ b/shared/src/main/kotlin/io/askimo/core/db/DatabaseManager.kt @@ -341,6 +341,14 @@ class DatabaseManager private constructor( stmt.executeUpdate("ALTER TABLE chat_messages ADD COLUMN duration_ms INTEGER") } catch (_: Exception) {} + // Migration: Add is_bookmarked column for message pinning feature. + // 0 = not bookmarked (default), 1 = bookmarked. + try { + stmt.executeUpdate("ALTER TABLE chat_messages ADD COLUMN is_bookmarked INTEGER DEFAULT 0") + } catch (_: Exception) { + // Column already exists — safe to ignore. + } + // Create composite index for efficient session-based queries with time ordering stmt.executeUpdate( """ diff --git a/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt b/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt index 4404af09a..2f7591dc7 100644 --- a/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt +++ b/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt @@ -6,6 +6,11 @@ package io.askimo.core.i18n import java.text.MessageFormat import java.text.NumberFormat +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle import java.util.Locale import java.util.Properties @@ -255,4 +260,32 @@ object LocalizationManager { // Silently ignore missing files — not all layers are required } } + + /** + * Formats an [Instant] as a short time string (e.g. "14:35") in the system default + * time zone using the current locale. + */ + fun formatMessageTime(timestamp: Instant): String { + val formatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + .withLocale(currentLocale) + .withZone(ZoneId.systemDefault()) + return formatter.format(timestamp) + } + + /** + * Formats an [Instant] as a day label relative to [currentDate]: + * "Today", "Yesterday", or a medium-length date string in the current locale. + */ + fun formatDayLabel(timestamp: Instant, currentDate: LocalDate): String { + val zone = ZoneId.systemDefault() + return when (val msgDate = timestamp.atZone(zone).toLocalDate()) { + currentDate -> getString("message.date.today") + + currentDate.minusDays(1) -> getString("message.date.yesterday") + + else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(currentLocale) + .format(msgDate) + } + } }