Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,11 @@ interface ChatActions {
fun retryMessage(messageId: String, enabledServerIds: Set<String> = 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)
}
2 changes: 2 additions & 0 deletions desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,7 @@ fun chatView(
onRetryMessage = { messageId -> actions.retryMessage(messageId, currentEnabledServerIds) },
viewportTopY = viewportBounds?.top,
projectId = project?.id,
onForkFromMessage = { messageId -> actions.forkFromMessage(messageId) },
)
}

Expand Down Expand Up @@ -1142,6 +1143,7 @@ fun chatView(
activeToolCalls = activeToolCalls,
bookmarkedMessageIds = bookmarkedMessageIds,
onToggleBookmark = { messageId -> actions.toggleBookmark(messageId) },
onForkFromMessage = { messageId -> actions.forkFromMessage(messageId) },
)
}
}
Expand Down
28 changes: 28 additions & 0 deletions desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -134,6 +135,7 @@ fun messageList(
activeToolCalls: List<ToolCallInfo> = emptyList(),
bookmarkedMessageIds: Set<String> = emptySet(),
onToggleBookmark: ((String) -> Unit)? = null,
onForkFromMessage: ((String) -> Unit)? = null,
) {
// Retry confirmation dialog state
var showRetryConfirmDialog by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -227,6 +229,7 @@ fun messageList(
toolCalls = if (isLastAiMsg) activeToolCalls else emptyList(),
bookmarkedMessageIds = bookmarkedMessageIds,
onToggleBookmark = onToggleBookmark,
onForkFromMessage = onForkFromMessage,
)
isFirstMessage = false
messageIndex++
Expand Down Expand Up @@ -324,6 +327,7 @@ fun messageBubble(
toolCalls: List<ToolCallInfo> = emptyList(),
bookmarkedMessageIds: Set<String> = emptySet(),
onToggleBookmark: ((String) -> Unit)? = null,
onForkFromMessage: ((String) -> Unit)? = null,
) {
Box(
modifier = Modifier
Expand Down Expand Up @@ -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,
)
}
}
Expand Down Expand Up @@ -630,6 +635,7 @@ private fun aiMessageBubble(
toolCalls: List<ToolCallInfo> = emptyList(),
isBookmarked: Boolean = false,
onToggleBookmark: ((String) -> Unit)? = null,
onForkFromMessage: ((String) -> Unit)? = null,
) {
val clipboardManager = LocalClipboardManager.current
var showCopyFeedback by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -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,
)
}
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,8 @@ 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
Expand Down
2 changes: 2 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,8 @@ 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)
Expand Down
2 changes: 2 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages_es.properties
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,8 @@ 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)
Expand Down
2 changes: 2 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages_fr.properties
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,8 @@ 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é)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,8 @@ 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=(編集済み)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,8 @@ 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=(편집됨)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,8 @@ 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,8 @@ 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,8 @@ 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=(已编辑)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,8 @@ 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=(已編輯)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatMessage>): List<ChatMessage> {
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<ChatMessage> = transaction(database) {
val messages = ChatMessagesTable
.selectAll()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading