From f89457a7106c927fafa1c4b6f91384677a966807 Mon Sep 17 00:00:00 2001 From: hiennguyen142 <294386332+hiennguyen142@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:52:55 -0700 Subject: [PATCH 1/3] feat(session): allow users to fork chat conversations - Enables the creation of a new, independent chat session - The new session is pre-populated with all messages up to the chosen point - Implements core session management and repository logic - Integrates the "Fork" action into the chat UI components --- .../core/chat/service/ChatSessionServiceIT.kt | 283 ++++++++++++++++++ .../kotlin/io/askimo/ui/chat/ChatActions.kt | 7 + .../main/kotlin/io/askimo/ui/chat/ChatView.kt | 2 + .../kotlin/io/askimo/ui/chat/ChatViewModel.kt | 28 ++ .../io/askimo/ui/chat/MessageComponents.kt | 24 ++ .../io/askimo/ui/shell/NavigationSidebar.kt | 1 - .../main/resources/i18n/messages.properties | 4 +- .../resources/i18n/messages_de.properties | 4 +- .../resources/i18n/messages_es.properties | 4 +- .../resources/i18n/messages_fr.properties | 4 +- .../resources/i18n/messages_ja_JP.properties | 4 +- .../resources/i18n/messages_ko_KR.properties | 4 +- .../resources/i18n/messages_pt_BR.properties | 4 +- .../resources/i18n/messages_vi_VN.properties | 4 +- .../resources/i18n/messages_zh_CN.properties | 4 +- .../resources/i18n/messages_zh_TW.properties | 4 +- .../chat/repository/ChatMessageRepository.kt | 50 ++++ .../core/chat/service/ChatSessionService.kt | 76 +++++ 18 files changed, 490 insertions(+), 21 deletions(-) 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 ff3cb1f9..76bd31ed 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 @@ -18,11 +18,14 @@ 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.assertNotEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertNotNull +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.io.TempDir import java.nio.file.Path import java.time.Instant @@ -672,4 +675,284 @@ class ChatSessionServiceIT { service.toggleBookmark(msg.id) // bookmark off assertTrue(service.getAllBookmarkGroups().isEmpty()) } + + // ------------------------------------------------------------------------- + // forkSession + // ------------------------------------------------------------------------- + + @Test + fun `forkSession should create a new session with 'Fork of' prefix in title`() { + val session = sessionRepository.createSession(ChatSession(id = "", title = "My Conversation")) + val baseTime = Instant.now() + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Hello", createdAt = baseTime)) + val aiMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "World", createdAt = baseTime.plusSeconds(1))) + + val forked = service.forkSession(session.id, aiMsg.id) + + assertEquals("Fork of: My Conversation", forked.title) + } + + @Test + fun `forkSession should inherit directiveId and projectId from source session`() { + val directive = directiveRepository.save(ChatDirective(id = "", name = "Directive", content = "Instructions")) + val session = sessionRepository.createSession(ChatSession(id = "", title = "With Meta", directiveId = directive.id)) + val baseTime = Instant.now() + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q", createdAt = baseTime)) + val aiMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A", createdAt = baseTime.plusSeconds(1))) + + val forked = service.forkSession(session.id, aiMsg.id) + + assertEquals(directive.id, forked.directiveId) + assertNull(forked.projectId) + } + + @Test + fun `forkSession should copy all active messages up to and including the target AI message`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Copy Test")) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q1", createdAt = baseTime)) + val aiMsg1 = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A1", createdAt = baseTime.plusSeconds(1))) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q2", createdAt = baseTime.plusSeconds(2))) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A2", createdAt = baseTime.plusSeconds(3))) + + val forked = service.forkSession(session.id, aiMsg1.id) + val forkedMessages = service.getMessages(forked.id) + + assertEquals(2, forkedMessages.size) + assertEquals("Q1", forkedMessages[0].content) + assertEquals("A1", forkedMessages[1].content) + } + + @Test + fun `forkSession on the last AI message copies all messages in the session`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Full Fork")) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q1", createdAt = baseTime)) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A1", createdAt = baseTime.plusSeconds(1))) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q2", createdAt = baseTime.plusSeconds(2))) + val lastAiMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A2", createdAt = baseTime.plusSeconds(3))) + + val forked = service.forkSession(session.id, lastAiMsg.id) + val forkedMessages = service.getMessages(forked.id) + + assertEquals(4, forkedMessages.size) + assertEquals(listOf("Q1", "A1", "Q2", "A2"), forkedMessages.map { it.content }) + } + + @Test + fun `forkSession on the first AI message copies only first two messages`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Shallow Fork")) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q1", createdAt = baseTime)) + val firstAiMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A1", createdAt = baseTime.plusSeconds(1))) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q2", createdAt = baseTime.plusSeconds(2))) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A2", createdAt = baseTime.plusSeconds(3))) + + val forked = service.forkSession(session.id, firstAiMsg.id) + val forkedMessages = service.getMessages(forked.id) + + assertEquals(2, forkedMessages.size) + assertEquals("Q1", forkedMessages[0].content) + assertEquals("A1", forkedMessages[1].content) + } + + @Test + fun `forkSession should not copy outdated messages`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Outdated Test")) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q1", createdAt = baseTime)) + val outdatedAi = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "Old answer", createdAt = baseTime.plusSeconds(1))) + // Mark the outdated branch + service.markMessagesAsOutdatedAfter(session.id, outdatedAi.id) + messageRepository.markMessageAsOutdated(outdatedAi.id) + // New active branch + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q2", createdAt = baseTime.plusSeconds(2))) + val activeAi = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "New answer", createdAt = baseTime.plusSeconds(3))) + + val forked = service.forkSession(session.id, activeAi.id) + val forkedMessages = service.getMessages(forked.id) + + // Only the two active messages should be copied + assertEquals(2, forkedMessages.size) + assertFalse(forkedMessages.any { it.content == "Old answer" }) + assertTrue(forkedMessages.any { it.content == "Q2" }) + assertTrue(forkedMessages.any { it.content == "New answer" }) + } + + @Test + fun `forkSession should assign new unique IDs to all copied messages`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "ID Test")) + val userMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q", createdAt = baseTime)) + val aiMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A", createdAt = baseTime.plusSeconds(1))) + + val forked = service.forkSession(session.id, aiMsg.id) + val forkedMessages = service.getMessages(forked.id) + + val originalIds = setOf(userMsg.id, aiMsg.id) + forkedMessages.forEach { msg -> + assertNotEquals(null, msg.id) + assertFalse(originalIds.contains(msg.id), "Forked message should have a new ID, not ${msg.id}") + } + } + + @Test + fun `forkSession should not transfer bookmarks to forked messages`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Bookmark Test")) + val userMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q", createdAt = baseTime)) + val aiMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A", createdAt = baseTime.plusSeconds(1))) + service.toggleBookmark(userMsg.id) + service.toggleBookmark(aiMsg.id) + + val forked = service.forkSession(session.id, aiMsg.id) + val forkedMessages = service.getMessages(forked.id) + + forkedMessages.forEach { msg -> + assertFalse(msg.isBookmarked, "Forked message '${msg.content}' should not be bookmarked") + } + } + + @Test + fun `forkSession should not transfer editParentId to forked messages`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "EditParent Test")) + val parentMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Original Q", createdAt = baseTime)) + val aiMsg = service.addMessage( + ChatMessage( + id = "", + sessionId = session.id, + role = MessageRole.ASSISTANT, + content = "A", + createdAt = baseTime.plusSeconds(1), + editParentId = parentMsg.id, + ), + ) + + val forked = service.forkSession(session.id, aiMsg.id) + val forkedMessages = service.getMessages(forked.id) + + forkedMessages.forEach { msg -> + assertNull(msg.editParentId, "Forked message should not carry editParentId") + } + } + + @Test + fun `forkSession should preserve message content and roles`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Content Test")) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "User question", createdAt = baseTime)) + val aiMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "AI answer", createdAt = baseTime.plusSeconds(1))) + + val forked = service.forkSession(session.id, aiMsg.id) + val forkedMessages = service.getMessages(forked.id) + + assertEquals(2, forkedMessages.size) + assertEquals("User question", forkedMessages[0].content) + assertTrue(forkedMessages[0].isUser) + assertEquals("AI answer", forkedMessages[1].content) + assertFalse(forkedMessages[1].isUser) + } + + @Test + fun `forkSession should leave the source session completely untouched`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Source Untouched")) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q1", createdAt = baseTime)) + val aiMsg1 = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A1", createdAt = baseTime.plusSeconds(1))) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q2", createdAt = baseTime.plusSeconds(2))) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A2", createdAt = baseTime.plusSeconds(3))) + + service.forkSession(session.id, aiMsg1.id) + + val sourceMessages = service.getMessages(session.id) + assertEquals(4, sourceMessages.size) + assertEquals(listOf("Q1", "A1", "Q2", "A2"), sourceMessages.map { it.content }) + } + + @Test + fun `forkSession should produce a new session independent from the source`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Independence")) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q", createdAt = baseTime)) + val aiMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A", createdAt = baseTime.plusSeconds(1))) + + val forked = service.forkSession(session.id, aiMsg.id) + + assertNotEquals(session.id, forked.id) + + // Adding a message to the fork must not appear in the source + service.addMessage(ChatMessage(id = "", sessionId = forked.id, role = MessageRole.USER, content = "Fork follow-up", createdAt = baseTime.plusSeconds(2))) + + val sourceMessages = service.getMessages(session.id) + assertFalse(sourceMessages.any { it.content == "Fork follow-up" }) + + val forkedMessages = service.getMessages(forked.id) + assertTrue(forkedMessages.any { it.content == "Fork follow-up" }) + } + + @Test + fun `forkSession throws IllegalArgumentException for unknown source session`() { + assertThrows { + service.forkSession("non-existent-session-id", "any-message-id") + } + } + + @Test + fun `forkSession throws IllegalArgumentException when message ID is not in active messages`() { + val session = sessionRepository.createSession(ChatSession(id = "", title = "Bad Message ID")) + val baseTime = Instant.now() + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q", createdAt = baseTime)) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A", createdAt = baseTime.plusSeconds(1))) + + assertThrows { + service.forkSession(session.id, "message-id-that-does-not-exist") + } + } + + @Test + fun `forkSession throws IllegalArgumentException when target message ID belongs to an outdated message`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Outdated Target")) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q", createdAt = baseTime)) + val outdatedAi = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "Old A", createdAt = baseTime.plusSeconds(1))) + // Mark the AI message itself as outdated + messageRepository.markMessageAsOutdated(outdatedAi.id) + + // The outdated message ID must not be found in the active message list + assertThrows { + service.forkSession(session.id, outdatedAi.id) + } + } + + @Test + fun `forkSession forked messages all belong to the new session`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Session Ownership")) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Q1", createdAt = baseTime)) + val aiMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "A1", createdAt = baseTime.plusSeconds(1))) + + val forked = service.forkSession(session.id, aiMsg.id) + // Use the domain-level repository to access sessionId on the raw ChatMessage + val forkedDomainMessages = messageRepository.getMessages(forked.id) + + forkedDomainMessages.forEach { msg -> + assertEquals(forked.id, msg.sessionId, "Every copied message must belong to the forked session") + } + } + + @Test + fun `forkSession forked session is retrievable via resumeSession`() { + val baseTime = Instant.now() + val session = sessionRepository.createSession(ChatSession(id = "", title = "Resumable Fork")) + service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Hello", createdAt = baseTime)) + val aiMsg = service.addMessage(ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "Hi there", createdAt = baseTime.plusSeconds(1))) + + val forked = service.forkSession(session.id, aiMsg.id) + val result = service.resumeSession(forked.id) + + assertTrue(result.success) + assertEquals(forked.id, result.sessionId) + assertEquals(2, result.messages.size) + } } 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 0ff6ccaa..09d2e071 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 @@ -26,4 +26,11 @@ interface ChatActions { fun retryMessage(messageId: String, enabledServerIds: Set = emptySet()) fun toggleBookmark(messageId: String) fun clearPendingScroll() + + /** + * Fork the current session from the given AI message, creating a new independent + * session pre-populated with all active messages up to and including [messageId], + * then navigate to the new session immediately. + */ + fun forkFromMessage(messageId: String) } 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 7dfcf754..f029af84 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 @@ -1105,6 +1105,7 @@ fun chatView( onRetryMessage = { messageId -> actions.retryMessage(messageId, currentEnabledServerIds) }, viewportTopY = viewportBounds?.top, projectId = project?.id, + onForkFromMessage = { messageId -> actions.forkFromMessage(messageId) }, ) } @@ -1142,6 +1143,7 @@ fun chatView( activeToolCalls = activeToolCalls, bookmarkedMessageIds = bookmarkedMessageIds, onToggleBookmark = { messageId -> actions.toggleBookmark(messageId) }, + onForkFromMessage = { messageId -> actions.forkFromMessage(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 3a9d9720..2136a038 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 @@ -1327,4 +1327,32 @@ class ChatViewModel( override fun clearPendingScroll() { pendingScrollToMessageId = null } + + /** + * Fork the current session from [messageId], creating a new independent session + * pre-populated with all active messages up to and including that message, then + * switching to the new session immediately. + * + * The operation runs on [Dispatchers.IO] and is fire-and-forget from the UI's + * perspective — no loading state is shown because forking is fast and non-blocking + * to the current conversation. + */ + override fun forkFromMessage(messageId: String) { + val sourceSessionId = currentSessionId.value ?: return + + scope.launch { + try { + val forkedSession = withContext(Dispatchers.IO) { + chatSessionService.forkSession(sourceSessionId, messageId) + } + // switchToSession updates activeSessionId and resumes the ViewModel + // for the new session — runs on the calling (main) dispatcher. + sessionManager.switchToSession(forkedSession.id) + log.debug("Forked session {} → navigated to {}", sourceSessionId, forkedSession.id) + } catch (e: Exception) { + log.error("Failed to fork session from message {}", messageId, e) + errorMessage = "Failed to fork conversation. Please try again." + } + } + } } 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 7c79e1e2..1603b3e7 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 @@ -30,6 +30,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape 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.automirrored.filled.CallSplit import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.BookmarkBorder @@ -134,6 +135,7 @@ fun messageList( activeToolCalls: List = emptyList(), bookmarkedMessageIds: Set = emptySet(), onToggleBookmark: ((String) -> Unit)? = null, + onForkFromMessage: ((String) -> Unit)? = null, ) { // Retry confirmation dialog state var showRetryConfirmDialog by remember { mutableStateOf(false) } @@ -227,6 +229,7 @@ fun messageList( toolCalls = if (isLastAiMsg) activeToolCalls else emptyList(), bookmarkedMessageIds = bookmarkedMessageIds, onToggleBookmark = onToggleBookmark, + onForkFromMessage = onForkFromMessage, ) isFirstMessage = false messageIndex++ @@ -324,6 +327,7 @@ fun messageBubble( toolCalls: List = emptyList(), bookmarkedMessageIds: Set = emptySet(), onToggleBookmark: ((String) -> Unit)? = null, + onForkFromMessage: ((String) -> Unit)? = null, ) { Box( modifier = Modifier @@ -362,6 +366,7 @@ fun messageBubble( toolCalls = toolCalls, isBookmarked = message.id != null && message.id in bookmarkedMessageIds, onToggleBookmark = if (message.id != null) onToggleBookmark else null, + onForkFromMessage = if (message.id != null) onForkFromMessage else null, ) } } @@ -630,6 +635,7 @@ private fun aiMessageBubble( toolCalls: List = emptyList(), isBookmarked: Boolean = false, onToggleBookmark: ((String) -> Unit)? = null, + onForkFromMessage: ((String) -> Unit)? = null, ) { val clipboardManager = LocalClipboardManager.current var showCopyFeedback by remember { mutableStateOf(false) } @@ -969,6 +975,24 @@ private fun aiMessageBubble( } } } + + // Fork session from here + if (onForkFromMessage != null && message.id != null && !isStreaming) { + val msgId = message.id!! + themedTooltip(text = stringResource("message.ai.fork")) { + IconButton( + onClick = { onForkFromMessage.invoke(msgId) }, + modifier = Modifier.size(32.dp).pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.CallSplit, + contentDescription = stringResource("message.ai.fork.description"), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } } 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 71b021da..bc41ac6a 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 @@ -117,7 +117,6 @@ interface ProjectsSidebarState { * * This keeps the sidebar decoupled from module-specific View enums and auth concerns */ -@OptIn(ExperimentalFoundationApi::class) @Composable fun navigationSidebar( isExpanded: Boolean, diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index 66d8124f..7707ef00 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -840,14 +840,14 @@ message.ai.try.again.confirm.title=Regenerate response? message.ai.try.again.confirm.message=This will delete all messages after this point. Do you want to continue? message.ai.try.again.confirm.cancel=Cancel message.ai.try.again.confirm.confirm=Regenerate +message.ai.fork=Fork session from here +message.ai.fork.description=Fork this conversation into a new independent session 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. diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index 10fbf272..c70d0be4 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -816,11 +816,11 @@ message.ai.try.again.confirm.title=Antwort neu generieren? message.ai.try.again.confirm.message=Alle Nachrichten nach diesem Punkt werden gelöscht. Möchten Sie fortfahren? message.ai.try.again.confirm.cancel=Abbrechen message.ai.try.again.confirm.confirm=Neu generieren +message.ai.fork=Session von hier aus forken +message.ai.fork.description=Diese Unterhaltung in eine neue, unabhängige Session forken message.ai.export.pdf=Als PDF exportieren message.ai.export.pdf.dialog.title=KI-Antwort als PDF speichern message.edited.indicator=(bearbeitet) -message.date.today=Heute -message.date.yesterday=Gestern message.token.usage={0} Tokens · {1} in / {2} out message.token.usage.tooltip=Von dieser Antwort verwendete Tokens.\nEingabe: Ihr Prompt + Kontext, der an die KI gesendet wurde.\nAusgabe: die KI-generierte Antwort. diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index 1b72a6d0..f39e3322 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -815,11 +815,11 @@ message.ai.try.again.confirm.title=¿Regenerar respuesta? message.ai.try.again.confirm.message=Esto eliminará todos los mensajes posteriores a este punto. ¿Desea continuar? message.ai.try.again.confirm.cancel=Cancelar message.ai.try.again.confirm.confirm=Regenerar +message.ai.fork=Fork session from here +message.ai.fork.description=Fork this conversation into a new independent session message.ai.export.pdf=Exportar como PDF message.ai.export.pdf.dialog.title=Guardar respuesta de IA como PDF message.edited.indicator=(editado) -message.date.today=Hoy -message.date.yesterday=Ayer message.token.usage={0} tokens · {1} de entrada / {2} de salida message.token.usage.tooltip=Tokens utilizados por esta respuesta.\nEntrada: su prompt + contexto enviado a la IA.\nSalida: la respuesta generada por la IA. diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index dc12a5c6..f95d46ae 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -815,11 +815,11 @@ message.ai.try.again.confirm.title=Régénérer la réponse ? message.ai.try.again.confirm.message=Tous les messages après ce point seront supprimés. Voulez-vous continuer ? message.ai.try.again.confirm.cancel=Annuler message.ai.try.again.confirm.confirm=Régénérer +message.ai.fork=Hacer fork de la sesión desde aquí +message.ai.fork.description=Dividir esta conversación en una nueva sesión independiente message.ai.export.pdf=Exporter en PDF message.ai.export.pdf.dialog.title=Enregistrer la reponse IA en PDF message.edited.indicator=(modifié) -message.date.today=Aujourd'hui -message.date.yesterday=Hier message.token.usage={0} tokens · {1} en entrée / {2} en sortie message.token.usage.tooltip=Tokens utilisés par cette réponse.\nEntrée: votre prompt + contexte envoyé à l'IA.\nSortie: la réponse générée par l'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 8b75aece..088cc388 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -814,11 +814,11 @@ message.ai.try.again.confirm.title=回答を再生成しますか? message.ai.try.again.confirm.message=この時点以降のすべてのメッセージが削除されます。続行しますか? message.ai.try.again.confirm.cancel=キャンセル message.ai.try.again.confirm.confirm=再生成 +message.ai.fork=ここからセッションをフォーク +message.ai.fork.description=この会話を新しい独立したセッションにフォークする message.ai.export.pdf=PDFとしてエクスポート message.ai.export.pdf.dialog.title=AIの回答をPDFとして保存 message.edited.indicator=(編集済み) -message.date.today=今日 -message.date.yesterday=昨日 message.token.usage={0} トークン · {1} 入力 / {2} 出力 message.token.usage.tooltip=この応答で使用されたトークン。\n入力: AIに送信されたプロンプトとコンテキスト。\n出力: AIが生成した返信。 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 daaaf463..2a745bac 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -815,11 +815,11 @@ message.ai.try.again.confirm.title=응답을 다시 생성하시겠습니까? message.ai.try.again.confirm.message=이 시점 이후의 모든 메시지가 삭제됩니다. 계속하시겠습니까? message.ai.try.again.confirm.cancel=취소 message.ai.try.again.confirm.confirm=다시 생성 +message.ai.fork=여기서 세션 포크(Fork) +message.ai.fork.description=이 대화를 새로운 독립 세션으로 포크(Fork)합니다 message.ai.export.pdf=PDF로 내보내기 message.ai.export.pdf.dialog.title=AI 응답을 PDF로 저장 message.edited.indicator=(편집됨) -message.date.today=오늘 -message.date.yesterday=어제 message.token.usage={0} 토큰 · {1} 입력 / {2} 출력 message.token.usage.tooltip=이 응답에 사용된 토큰입니다.\n입력: AI로 전송된 프롬프트 및 컨텍스트.\n출력: 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 133a2b2d..f218f31b 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -817,11 +817,11 @@ message.ai.try.again.confirm.title=Regenerar resposta? message.ai.try.again.confirm.message=Isso excluirá todas as mensagens após este ponto. Deseja continuar? message.ai.try.again.confirm.cancel=Cancelar message.ai.try.again.confirm.confirm=Regenerar +message.ai.fork=Fazer fork da sessão a partir daqui +message.ai.fork.description=Dividir esta conversa em uma nova sessão independente message.ai.export.pdf=Exportar como PDF message.ai.export.pdf.dialog.title=Salvar resposta da IA como PDF message.edited.indicator=(editado) -message.date.today=Hoje -message.date.yesterday=Ontem message.token.usage={0} tokens · {1} de entrada / {2} de saída message.token.usage.tooltip=Tokens usados por esta resposta.\nEntrada: seu prompt + contexto enviado à IA.\nSaída: a resposta gerada pela 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 78549c36..ec254c56 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -813,11 +813,11 @@ message.ai.try.again.confirm.title=Tạo lại phản hồi? message.ai.try.again.confirm.message=Tất cả các tin nhắn sau thời điểm này sẽ bị xóa. Bạn có muốn tiếp tục không? message.ai.try.again.confirm.cancel=Hủy message.ai.try.again.confirm.confirm=Tạo lại +message.ai.fork=Fork phiên từ đây +message.ai.fork.description=Fork cuộc trò chuyện này thành một phiên mới độc lập message.ai.export.pdf=Xuất ra PDF message.ai.export.pdf.dialog.title=Lưu phản hồi AI dưới dạng PDF message.edited.indicator=(đã chỉnh sửa) -message.date.today=Hôm nay -message.date.yesterday=Hôm qua message.token.usage={0} token · {1} vào / {2} ra message.token.usage.tooltip=Số token được sử dụng bởi phản hồi này.\nĐầu vào: prompt của bạn + ngữ cảnh được gửi đến AI.\nĐầu ra: phản hồi do AI tạo ra. 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 c48ff8eb..c8f85726 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -814,11 +814,11 @@ message.ai.try.again.confirm.title=重新生成回复? message.ai.try.again.confirm.message=此操作将删除此位置之后的所有消息。是否继续? message.ai.try.again.confirm.cancel=取消 message.ai.try.again.confirm.confirm=重新生成 +message.ai.fork=从此处 Fork 会话 +message.ai.fork.description=将此对话 Fork 为一个新的独立会话 message.ai.export.pdf=导出为 PDF message.ai.export.pdf.dialog.title=将 AI 回复保存为 PDF message.edited.indicator=(已编辑) -message.date.today=今天 -message.date.yesterday=昨天 message.token.usage={0} 个 token · {1} 进 / {2} 出 message.token.usage.tooltip=此回复使用的 token。\n输入: 您的 prompt + 发送给 AI 的上下文。\n输出: 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 cc9f6f99..6f755aa2 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -814,11 +814,11 @@ message.ai.try.again.confirm.title=重新產生回應? message.ai.try.again.confirm.message=此操作將刪除此位置之後的所有訊息。是否要繼續? message.ai.try.again.confirm.cancel=取消 message.ai.try.again.confirm.confirm=重新產生 +message.ai.fork=從此處 Fork 會話 +message.ai.fork.description=將此對話 Fork 為一個新的獨立會話 message.ai.export.pdf=匯出為 PDF message.ai.export.pdf.dialog.title=將 AI 回覆儲存為 PDF message.edited.indicator=(已編輯) -message.date.today=今天 -message.date.yesterday=昨天 message.token.usage={0} 個 token · {1} 進 / {2} 出 message.token.usage.tooltip=此回覆使用的 token。\n輸入: 您的 prompt + 傳送給 AI 的上下文。\n輸出: AI 產生的回覆。 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 b48fd7ab..67db7763 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 @@ -132,6 +132,56 @@ class ChatMessageRepository internal constructor( return messageWithInjectedFields } + /** + * Bulk-insert a list of messages in a single transaction. + * Intended for operations like session forking where all messages must be + * written atomically. + * + * @param messages The messages to insert. Empty IDs are replaced with new UUIDs. + * @return The inserted messages with their generated IDs. + */ + fun addMessages(messages: List): List { + if (messages.isEmpty()) return emptyList() + + val messagesWithIds = messages.map { message -> + message.copy(id = message.id.ifBlank { UUID.randomUUID().toString() }) + } + + transaction(database) { + messagesWithIds.forEach { msg -> + ChatMessagesTable.insert { + it[id] = msg.id + it[ChatMessagesTable.sessionId] = msg.sessionId + it[ChatMessagesTable.role] = msg.role.value + it[ChatMessagesTable.content] = msg.content + it[createdAt] = msg.createdAt + it[ChatMessagesTable.isOutdated] = if (msg.isOutdated) 1 else 0 + it[ChatMessagesTable.editParentId] = msg.editParentId + it[ChatMessagesTable.isEdited] = if (msg.isEdited) 1 else 0 + it[ChatMessagesTable.isFailed] = if (msg.isFailed) 1 else 0 + it[ChatMessagesTable.inputTokens] = msg.inputTokens + it[ChatMessagesTable.outputTokens] = msg.outputTokens + it[ChatMessagesTable.totalTokens] = msg.totalTokens + it[ChatMessagesTable.durationMs] = msg.durationMs + } + + if (msg.attachments.isNotEmpty()) { + attachmentRepository.addAttachments( + msg.attachments.map { attachment -> + attachment.copy( + id = attachment.id.ifEmpty { UUID.randomUUID().toString() }, + messageId = msg.id, + sessionId = msg.sessionId, + ) + }, + ) + } + } + } + + return messagesWithIds + } + fun getMessages(sessionId: String): List = transaction(database) { val messages = ChatMessagesTable .selectAll() 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 f7ac7c09..88e13f23 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 @@ -379,6 +379,82 @@ class ChatSessionService( return createdSession } + /** + * Fork a session from a specific AI message. + * + * Creates a new independent session pre-populated with all active (non-outdated) + * messages from [sourceSessionId] up to and including [upToMessageId]. + * The forked session inherits the source session's [ChatSession.directiveId] and + * [ChatSession.projectId]. All messages are given fresh IDs; the source session is + * left completely untouched. + * + * @param sourceSessionId The ID of the session to fork from. + * @param upToMessageId The ID of the AI message to fork from (inclusive). + * @return The newly created (forked) [ChatSession]. + * @throws IllegalArgumentException if [sourceSessionId] does not exist or + * [upToMessageId] is not found among its active messages. + */ + fun forkSession(sourceSessionId: String, upToMessageId: String): ChatSession { + val sourceSession = sessionRepository.getSession(sourceSessionId) + ?: throw IllegalArgumentException("Source session $sourceSessionId not found") + + // Collect active messages up to and including the target message (chronological order). + val allMessages = messageRepository.getMessages(sourceSessionId) + val activeMessages = allMessages.filter { !it.isOutdated } + val targetIndex = activeMessages.indexOfFirst { it.id == upToMessageId } + require(targetIndex >= 0) { + "Message $upToMessageId not found in active messages of session $sourceSessionId" + } + val messagesToCopy = activeMessages.subList(0, targetIndex + 1) + + // Create the forked session (no AI title generation — title is already descriptive). + val forkedSession = sessionRepository.createSession( + ChatSession( + id = "", + title = "Fork of: ${sourceSession.title}", + directiveId = sourceSession.directiveId, + projectId = sourceSession.projectId, + ), + ) + + // Bulk-insert all copied messages under the new session ID with fresh UUIDs. + val copiedMessages = messagesToCopy.map { msg -> + msg.copy( + id = "", // auto-UUID in addMessages() + sessionId = forkedSession.id, + isOutdated = false, + isBookmarked = false, + editParentId = null, + ) + } + messageRepository.addMessages(copiedMessages) + + // Update the session's updatedAt timestamp to reflect the bulk write. + sessionRepository.touchSession(forkedSession.id) + + // Warm up a chat client for the new session so it is ready immediately. + getOrCreateClientForSession(forkedSession.id) + + eventScope.launch { + EventBus.emit( + SessionCreatedEvent( + sessionId = forkedSession.id, + projectId = forkedSession.projectId, + ), + ) + } + + log.info( + "Forked session {} from session {} up to message {} ({} messages copied)", + forkedSession.id, + sourceSessionId, + upToMessageId, + copiedMessages.size, + ) + + return forkedSession + } + /** * Delete a session and all its related data (messages and summaries). * This method coordinates the deletion across multiple repositories. From 7b25914f045f617695c0f0e0a04011acd51c4637 Mon Sep 17 00:00:00 2001 From: hiennguyen142 <294386332+hiennguyen142@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:02:32 -0700 Subject: [PATCH 2/3] Add missing keys --- desktop-shared/src/main/resources/i18n/messages.properties | 2 ++ desktop-shared/src/main/resources/i18n/messages_de.properties | 2 ++ desktop-shared/src/main/resources/i18n/messages_es.properties | 2 ++ desktop-shared/src/main/resources/i18n/messages_fr.properties | 2 ++ .../src/main/resources/i18n/messages_ja_JP.properties | 2 ++ .../src/main/resources/i18n/messages_ko_KR.properties | 2 ++ .../src/main/resources/i18n/messages_pt_BR.properties | 2 ++ .../src/main/resources/i18n/messages_vi_VN.properties | 2 ++ .../src/main/resources/i18n/messages_zh_CN.properties | 2 ++ .../src/main/resources/i18n/messages_zh_TW.properties | 2 ++ 10 files changed, 20 insertions(+) diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index 7707ef00..451f628f 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -848,6 +848,8 @@ 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. diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index c70d0be4..359b0a8a 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -821,6 +821,8 @@ message.ai.fork.description=Diese Unterhaltung in eine neue, unabhängige Sessio message.ai.export.pdf=Als PDF exportieren message.ai.export.pdf.dialog.title=KI-Antwort als PDF speichern message.edited.indicator=(bearbeitet) +message.date.today=Heute +message.date.yesterday=Gestern message.token.usage={0} Tokens · {1} in / {2} out message.token.usage.tooltip=Von dieser Antwort verwendete Tokens.\nEingabe: Ihr Prompt + Kontext, der an die KI gesendet wurde.\nAusgabe: die KI-generierte Antwort. diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index f39e3322..b1777870 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -820,6 +820,8 @@ message.ai.fork.description=Fork this conversation into a new independent sessio message.ai.export.pdf=Exportar como PDF message.ai.export.pdf.dialog.title=Guardar respuesta de IA como PDF message.edited.indicator=(editado) +message.date.today=Hoy +message.date.yesterday=Ayer message.token.usage={0} tokens · {1} de entrada / {2} de salida message.token.usage.tooltip=Tokens utilizados por esta respuesta.\nEntrada: su prompt + contexto enviado a la IA.\nSalida: la respuesta generada por la IA. diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index f95d46ae..337fa90b 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -820,6 +820,8 @@ message.ai.fork.description=Dividir esta conversación en una nueva sesión inde message.ai.export.pdf=Exporter en PDF message.ai.export.pdf.dialog.title=Enregistrer la reponse IA en PDF message.edited.indicator=(modifié) +message.date.today=Aujourd'hui +message.date.yesterday=Hier message.token.usage={0} tokens · {1} en entrée / {2} en sortie message.token.usage.tooltip=Tokens utilisés par cette réponse.\nEntrée: votre prompt + contexte envoyé à l'IA.\nSortie: la réponse générée par l'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 088cc388..faef32bb 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -819,6 +819,8 @@ message.ai.fork.description=この会話を新しい独立したセッション message.ai.export.pdf=PDFとしてエクスポート message.ai.export.pdf.dialog.title=AIの回答をPDFとして保存 message.edited.indicator=(編集済み) +message.date.today=今日 +message.date.yesterday=昨日 message.token.usage={0} トークン · {1} 入力 / {2} 出力 message.token.usage.tooltip=この応答で使用されたトークン。\n入力: AIに送信されたプロンプトとコンテキスト。\n出力: AIが生成した返信。 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 2a745bac..d8782ba6 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -820,6 +820,8 @@ message.ai.fork.description=이 대화를 새로운 독립 세션으로 포크(F message.ai.export.pdf=PDF로 내보내기 message.ai.export.pdf.dialog.title=AI 응답을 PDF로 저장 message.edited.indicator=(편집됨) +message.date.today=오늘 +message.date.yesterday=어제 message.token.usage={0} 토큰 · {1} 입력 / {2} 출력 message.token.usage.tooltip=이 응답에 사용된 토큰입니다.\n입력: AI로 전송된 프롬프트 및 컨텍스트.\n출력: 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 f218f31b..de6e6996 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -822,6 +822,8 @@ message.ai.fork.description=Dividir esta conversa em uma nova sessão independen message.ai.export.pdf=Exportar como PDF message.ai.export.pdf.dialog.title=Salvar resposta da IA como PDF message.edited.indicator=(editado) +message.date.today=Hoje +message.date.yesterday=Ontem message.token.usage={0} tokens · {1} de entrada / {2} de saída message.token.usage.tooltip=Tokens usados por esta resposta.\nEntrada: seu prompt + contexto enviado à IA.\nSaída: a resposta gerada pela 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 ec254c56..2e384da6 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -818,6 +818,8 @@ message.ai.fork.description=Fork cuộc trò chuyện này thành một phiên m message.ai.export.pdf=Xuất ra PDF message.ai.export.pdf.dialog.title=Lưu phản hồi AI dưới dạng PDF message.edited.indicator=(đã chỉnh sửa) +message.date.today=Hôm nay +message.date.yesterday=Hôm qua message.token.usage={0} token · {1} vào / {2} ra message.token.usage.tooltip=Số token được sử dụng bởi phản hồi này.\nĐầu vào: prompt của bạn + ngữ cảnh được gửi đến AI.\nĐầu ra: phản hồi do AI tạo ra. 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 c8f85726..6dca0a0c 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -819,6 +819,8 @@ message.ai.fork.description=将此对话 Fork 为一个新的独立会话 message.ai.export.pdf=导出为 PDF message.ai.export.pdf.dialog.title=将 AI 回复保存为 PDF message.edited.indicator=(已编辑) +message.date.today=今天 +message.date.yesterday=昨天 message.token.usage={0} 个 token · {1} 进 / {2} 出 message.token.usage.tooltip=此回复使用的 token。\n输入: 您的 prompt + 发送给 AI 的上下文。\n输出: 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 6f755aa2..ac6364bb 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -819,6 +819,8 @@ message.ai.fork.description=將此對話 Fork 為一個新的獨立會話 message.ai.export.pdf=匯出為 PDF message.ai.export.pdf.dialog.title=將 AI 回覆儲存為 PDF message.edited.indicator=(已編輯) +message.date.today=今天 +message.date.yesterday=昨天 message.token.usage={0} 個 token · {1} 進 / {2} 出 message.token.usage.tooltip=此回覆使用的 token。\n輸入: 您的 prompt + 傳送給 AI 的上下文。\n輸出: AI 產生的回覆。 From 376fae08b2924c6a527ef277110057839f96584f Mon Sep 17 00:00:00 2001 From: hiennguyen142 <294386332+hiennguyen142@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:08:48 -0700 Subject: [PATCH 3/3] Update tooltip placement for session --- .../src/main/kotlin/io/askimo/ui/session/SessionTooltip.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionTooltip.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionTooltip.kt index a197abb0..85f148b6 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionTooltip.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionTooltip.kt @@ -40,7 +40,7 @@ import io.askimo.ui.common.ui.themedRichTooltip @Composable fun sessionTooltip( session: ChatSession, - placement: TooltipPlacement = TooltipPlacement.AUTO, + placement: TooltipPlacement = TooltipPlacement.RIGHT, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) {