diff --git a/cli/src/test/kotlin/io/askimo/core/chat/repository/ChatSessionDirectiveRepositoryIT.kt b/cli/src/test/kotlin/io/askimo/core/chat/repository/ChatSessionDirectiveRepositoryIT.kt new file mode 100644 index 00000000..df9d1440 --- /dev/null +++ b/cli/src/test/kotlin/io/askimo/core/chat/repository/ChatSessionDirectiveRepositoryIT.kt @@ -0,0 +1,156 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.core.chat.repository + +import io.askimo.core.chat.domain.ChatDirective +import io.askimo.core.chat.domain.ChatSession +import io.askimo.core.db.DatabaseManager +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.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import java.time.Instant + +class ChatSessionDirectiveRepositoryIT { + + @AfterEach + fun tearDown() { + sessionRepository.deleteAll() + directiveRepository.list().forEach { directiveRepository.delete(it.id) } + } + + @Test + fun `stores multiple active directives for one session`() { + val first = directiveRepository.save(ChatDirective(name = "Alpha", content = "First")) + val second = directiveRepository.save(ChatDirective(name = "Beta", content = "Second")) + val session = sessionRepository.createSession(ChatSession(id = "", title = "Multiple directives")) + + assertTrue(sessionRepository.replaceSessionDirectives(session.id, setOf(first.id, second.id))) + + val expectedIds = listOf(first.id, second.id).sorted() + assertEquals(expectedIds, sessionRepository.getActiveDirectiveIds(session.id).toList()) + assertEquals(expectedIds.first(), sessionRepository.getSession(session.id)?.directiveId) + } + + @Test + fun `toggling one directive preserves the other active directives`() { + val first = directiveRepository.save(ChatDirective(name = "Alpha", content = "First")) + val second = directiveRepository.save(ChatDirective(name = "Beta", content = "Second")) + val session = sessionRepository.createSession(ChatSession(id = "", title = "Toggle directives")) + sessionRepository.replaceSessionDirectives(session.id, setOf(first.id, second.id)) + + assertTrue(sessionRepository.setSessionDirectiveActive(session.id, first.id, false)) + + assertEquals(setOf(second.id), sessionRepository.getActiveDirectiveIds(session.id)) + assertEquals(second.id, sessionRepository.getSession(session.id)?.directiveId) + } + + @Test + fun `active directive state is isolated by session`() { + val first = directiveRepository.save(ChatDirective(name = "Alpha", content = "First")) + val second = directiveRepository.save(ChatDirective(name = "Beta", content = "Second")) + val firstSession = sessionRepository.createSession(ChatSession(id = "", title = "First session")) + val secondSession = sessionRepository.createSession(ChatSession(id = "", title = "Second session")) + + sessionRepository.setSessionDirectiveActive(firstSession.id, first.id, true) + sessionRepository.setSessionDirectiveActive(secondSession.id, second.id, true) + + assertEquals(setOf(first.id), sessionRepository.getActiveDirectiveIds(firstSession.id)) + assertEquals(setOf(second.id), sessionRepository.getActiveDirectiveIds(secondSession.id)) + } + + @Test + fun `deleting a directive refreshes every affected session`() { + val removed = directiveRepository.save(ChatDirective(name = "Temporary", content = "Temporary")) + val retained = directiveRepository.save(ChatDirective(name = "Retained", content = "Retained")) + val firstSession = sessionRepository.createSession(ChatSession(id = "", title = "First session")) + val secondSession = sessionRepository.createSession(ChatSession(id = "", title = "Second session")) + sessionRepository.replaceSessionDirectives(firstSession.id, setOf(removed.id, retained.id)) + sessionRepository.setSessionDirectiveActive(secondSession.id, removed.id, true) + + assertTrue(directiveRepository.delete(removed.id)) + + assertEquals(setOf(retained.id), sessionRepository.getActiveDirectiveIds(firstSession.id)) + assertEquals(retained.id, sessionRepository.getSession(firstSession.id)?.directiveId) + assertTrue(sessionRepository.getActiveDirectiveIds(secondSession.id).isEmpty()) + assertEquals(null, sessionRepository.getSession(secondSession.id)?.directiveId) + } + + @Test + fun `newer legacy server selection replaces the local active set`() { + val first = directiveRepository.save(ChatDirective(name = "First", content = "First")) + val second = directiveRepository.save(ChatDirective(name = "Second", content = "Second")) + val session = sessionRepository.createSession(ChatSession(id = "", title = "Synced session")) + sessionRepository.replaceSessionDirectives(session.id, setOf(first.id, second.id)) + + sessionRepository.upsertFromServer( + listOf( + session.copy( + directiveId = second.id, + updatedAt = Instant.now().plusSeconds(60), + ), + ), + ) + + assertEquals(setOf(second.id), sessionRepository.getActiveDirectiveIds(session.id)) + } + + @Test + fun `activating another directive preserves a delayed legacy selection`() { + val legacyDirectiveId = "legacy-directive" + val session = ChatSession( + id = "legacy-session", + title = "Delayed directive", + directiveId = legacyDirectiveId, + ) + sessionRepository.upsertFromServer(listOf(session)) + val additional = directiveRepository.save(ChatDirective(name = "Additional", content = "Additional")) + + sessionRepository.setSessionDirectiveActive(session.id, additional.id, true) + + assertEquals(setOf(additional.id), sessionRepository.getActiveDirectiveIds(session.id)) + assertEquals(legacyDirectiveId, sessionRepository.getSession(session.id)?.directiveId) + + directiveRepository.upsertFromServer( + listOf(ChatDirective(id = legacyDirectiveId, name = "Legacy", content = "Legacy")), + ) + + val expectedDirectiveIds = listOf(legacyDirectiveId, additional.id).sorted() + assertEquals( + expectedDirectiveIds, + sessionRepository.getActiveDirectiveIds(session.id).toList(), + ) + assertEquals(expectedDirectiveIds.first(), sessionRepository.getSession(session.id)?.directiveId) + } + + companion object { + private lateinit var testBaseScope: AskimoHome.TestBaseScope + private lateinit var databaseManager: DatabaseManager + private lateinit var sessionRepository: ChatSessionRepository + private lateinit var directiveRepository: ChatDirectiveRepository + + @JvmStatic + @BeforeAll + fun setUpClass(@TempDir tempDir: Path) { + testBaseScope = AskimoHome.withTestBase(tempDir) + databaseManager = DatabaseManager.getInMemoryTestInstance(this) + sessionRepository = databaseManager.getChatSessionRepository() + directiveRepository = databaseManager.getChatDirectiveRepository() + } + + @JvmStatic + @AfterAll + fun tearDownClass() { + if (::databaseManager.isInitialized) databaseManager.close() + DatabaseManager.reset() + if (::testBaseScope.isInitialized) testBaseScope.close() + } + } +} diff --git a/cli/src/test/kotlin/io/askimo/core/chat/service/ChatSessionExporterServiceIT.kt b/cli/src/test/kotlin/io/askimo/core/chat/service/ChatSessionExporterServiceIT.kt new file mode 100644 index 00000000..8fca588f --- /dev/null +++ b/cli/src/test/kotlin/io/askimo/core/chat/service/ChatSessionExporterServiceIT.kt @@ -0,0 +1,89 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.core.chat.service + +import com.fasterxml.jackson.databind.ObjectMapper +import io.askimo.core.chat.domain.ChatDirective +import io.askimo.core.chat.domain.ChatSession +import io.askimo.core.chat.repository.ChatDirectiveRepository +import io.askimo.core.chat.repository.ChatSessionRepository +import io.askimo.core.db.DatabaseManager +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.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class ChatSessionExporterServiceIT { + + @AfterEach + fun tearDown() { + sessionRepository.deleteAll() + directiveRepository.list().forEach { directiveRepository.delete(it.id) } + } + + @Test + fun `json compatibility directive matches the exported active set`(@TempDir exportDir: Path) { + val legacyDirectiveId = "unresolved-legacy-directive" + val activeDirectiveId = "active-directive" + val session = ChatSession( + id = "export-session", + title = "Directive export", + directiveId = legacyDirectiveId, + ) + sessionRepository.upsertFromServer(listOf(session)) + directiveRepository.save( + ChatDirective(id = activeDirectiveId, name = "Active", content = "Active content"), + ) + sessionRepository.setSessionDirectiveActive(session.id, activeDirectiveId, true) + + assertEquals(legacyDirectiveId, sessionRepository.getSession(session.id)?.directiveId) + assertEquals(setOf(activeDirectiveId), sessionRepository.getActiveDirectiveIds(session.id)) + + val exportPath = exportDir.resolve("session.json") + val result = exporterService.exportToJson(session.id, exportPath.toString()) + + assertTrue(result.isSuccess, result.exceptionOrNull()?.message) + val exportedJson = ObjectMapper().readTree(exportPath.toFile()) + assertEquals(activeDirectiveId, exportedJson["directiveId"].asText()) + assertEquals( + listOf(activeDirectiveId), + exportedJson["directiveIds"].map { it.asText() }, + ) + } + + companion object { + private lateinit var testBaseScope: AskimoHome.TestBaseScope + private lateinit var databaseManager: DatabaseManager + private lateinit var sessionRepository: ChatSessionRepository + private lateinit var directiveRepository: ChatDirectiveRepository + private lateinit var exporterService: ChatSessionExporterService + + @JvmStatic + @BeforeAll + fun setUpClass(@TempDir tempDir: Path) { + testBaseScope = AskimoHome.withTestBase(tempDir) + databaseManager = DatabaseManager.getInMemoryTestInstance(this) + sessionRepository = databaseManager.getChatSessionRepository() + directiveRepository = databaseManager.getChatDirectiveRepository() + exporterService = ChatSessionExporterService( + sessionRepository = sessionRepository, + messageRepository = databaseManager.getChatMessageRepository(), + ) + } + + @JvmStatic + @AfterAll + fun tearDownClass() { + if (::databaseManager.isInitialized) databaseManager.close() + DatabaseManager.reset() + if (::testBaseScope.isInitialized) testBaseScope.close() + } + } +} 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 9db5dd80..8a139580 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 @@ -414,6 +414,27 @@ class ChatSessionServiceIT { assertEquals(directive.id, result.directiveId) } + @Test + fun `resumeSessionPaginated should return all active directives`() { + val firstDirective = directiveRepository.save( + ChatDirective(name = "First Directive", content = "First instruction"), + ) + val secondDirective = directiveRepository.save( + ChatDirective(name = "Second Directive", content = "Second instruction"), + ) + val session = sessionRepository.createSession( + ChatSession(id = "", title = "Session with multiple directives"), + ) + service.updateSessionDirectives(session.id, setOf(firstDirective.id, secondDirective.id)) + + val result = service.resumeSessionPaginated(session.id, limit = 10) + + assertTrue(result.success) + val expectedDirectiveIds = listOf(firstDirective.id, secondDirective.id).sorted() + assertEquals(expectedDirectiveIds, result.activeDirectiveIds.toList()) + assertEquals(expectedDirectiveIds.first(), result.directiveId) + } + @Test fun `resumeSessionPaginated should handle limit of 1`() { // Given diff --git a/cli/src/test/kotlin/io/askimo/core/db/SessionDirectiveMigrationIT.kt b/cli/src/test/kotlin/io/askimo/core/db/SessionDirectiveMigrationIT.kt new file mode 100644 index 00000000..e03e12cc --- /dev/null +++ b/cli/src/test/kotlin/io/askimo/core/db/SessionDirectiveMigrationIT.kt @@ -0,0 +1,85 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.core.db + +import io.askimo.core.util.AskimoHome +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.sql.DriverManager + +class SessionDirectiveMigrationIT { + + @TempDir + lateinit var tempDir: Path + + private var testBaseScope: AskimoHome.TestBaseScope? = null + + @AfterEach + fun tearDown() { + DatabaseManager.reset() + testBaseScope?.close() + } + + @Test + fun `migrates a legacy directive once and does not reactivate it after disabling`() { + testBaseScope = AskimoHome.withTestBase(tempDir) + Files.createDirectories(AskimoHome.base()) + val databasePath = AskimoHome.base().resolve("askimo.db") + + DriverManager.getConnection("jdbc:sqlite:$databasePath").use { connection -> + connection.createStatement().use { statement -> + statement.executeUpdate( + """ + CREATE TABLE chat_directives ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """.trimIndent(), + ) + statement.executeUpdate( + """ + CREATE TABLE chat_sessions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + directive_id TEXT, + folder_id TEXT, + is_starred INTEGER DEFAULT 0, + synced_at TEXT + ) + """.trimIndent(), + ) + statement.executeUpdate( + "INSERT INTO chat_directives (id, name, content, created_at) " + + "VALUES ('directive-1', 'Legacy', 'Legacy content', '2026-01-01T00:00:00Z')", + ) + statement.executeUpdate( + "INSERT INTO chat_sessions (id, title, created_at, updated_at, directive_id) " + + "VALUES ('session-1', 'Legacy session', '2026-01-01T00:00:00Z', " + + "'2026-01-01T00:00:00Z', 'directive-1')", + ) + } + } + + val databaseManager = DatabaseManager.getInstance() + val repository = databaseManager.getChatSessionRepository() + assertEquals(setOf("directive-1"), repository.getActiveDirectiveIds("session-1")) + + assertTrue(repository.setSessionDirectiveActive("session-1", "directive-1", false)) + assertTrue(repository.getActiveDirectiveIds("session-1").isEmpty()) + + DatabaseManager.reset() + val reopenedRepository = DatabaseManager.getInstance().getChatSessionRepository() + assertTrue(reopenedRepository.getActiveDirectiveIds("session-1").isEmpty()) + } +} diff --git a/cli/src/test/kotlin/io/askimo/core/providers/ChatRequestTransformersTest.kt b/cli/src/test/kotlin/io/askimo/core/providers/ChatRequestTransformersTest.kt index 379af779..c2e26687 100644 --- a/cli/src/test/kotlin/io/askimo/core/providers/ChatRequestTransformersTest.kt +++ b/cli/src/test/kotlin/io/askimo/core/providers/ChatRequestTransformersTest.kt @@ -9,6 +9,8 @@ import dev.langchain4j.data.message.ChatMessage import dev.langchain4j.data.message.SystemMessage import dev.langchain4j.data.message.UserMessage import dev.langchain4j.model.chat.request.ChatRequest +import io.askimo.core.chat.domain.ChatDirective +import io.askimo.core.chat.domain.ChatSession import io.askimo.core.context.AppContext import io.askimo.core.context.ExecutionMode import io.askimo.core.db.DatabaseManager @@ -355,6 +357,62 @@ class ChatRequestTransformersTest { } } + @Nested + @DisplayName("Session Directives") + inner class SessionDirectiveTests { + + @Test + fun `should add every active session directive in name order`() { + val directiveRepository = DatabaseManager.getInstance().getChatDirectiveRepository() + val sessionRepository = DatabaseManager.getInstance().getChatSessionRepository() + val second = directiveRepository.save(ChatDirective(name = "Beta", content = "Second instruction")) + val first = directiveRepository.save(ChatDirective(name = "Alpha", content = "First instruction")) + val session = sessionRepository.createSession(ChatSession(id = "", title = "Multiple directives")) + sessionRepository.replaceSessionDirectives(session.id, setOf(second.id, first.id)) + val request = ChatRequest.builder().messages(listOf(UserMessage.from("Hello"))).build() + + val result = ChatRequestTransformers.addCustomSystemMessagesAndRemoveDuplicates( + sessionId = session.id, + chatRequest = request, + memoryId = null, + provider = ModelProvider.OPENAI, + settings = OpenAiSettings(defaultModel = "gpt-4"), + ) + + val systemTexts = result.messages().filterIsInstance().map { it.text() } + assertEquals(listOf("First instruction", "Second instruction"), systemTexts) + } + + @Test + fun `should skip blank and duplicate active directive content`() { + val directiveRepository = DatabaseManager.getInstance().getChatDirectiveRepository() + val sessionRepository = DatabaseManager.getInstance().getChatSessionRepository() + val duplicate = directiveRepository.save(ChatDirective(name = "Duplicate", content = "Existing instruction")) + val blank = directiveRepository.save(ChatDirective(name = "Blank", content = "")) + val session = sessionRepository.createSession(ChatSession(id = "", title = "Filtered directives")) + sessionRepository.replaceSessionDirectives(session.id, setOf(duplicate.id, blank.id)) + val request = ChatRequest.builder() + .messages( + listOf( + SystemMessage.from("Existing instruction"), + UserMessage.from("Hello"), + ), + ) + .build() + + val result = ChatRequestTransformers.addCustomSystemMessagesAndRemoveDuplicates( + sessionId = session.id, + chatRequest = request, + memoryId = null, + provider = ModelProvider.OPENAI, + settings = OpenAiSettings(defaultModel = "gpt-4"), + ) + + val systemTexts = result.messages().filterIsInstance().map { it.text() } + assertEquals(listOf("Existing instruction"), systemTexts) + } + } + @Nested @DisplayName("Model Provider Variations") inner class ModelProviderTests { 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 7535d627..5f15458a 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 @@ -21,7 +21,7 @@ interface ChatActions { fun clearSearch() fun nextSearchResult() fun previousSearchResult() - fun setDirective(directiveId: String?) + fun setDirectiveActive(directiveId: String, active: Boolean) fun updateAIMessage(messageId: String, newContent: String) fun retryMessage(messageId: String, enabledServerIds: Set = emptySet()) } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt index 61375869..498441af 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt @@ -23,13 +23,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close @@ -74,6 +79,7 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -81,9 +87,17 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import io.askimo.core.chat.domain.ChatDirective +import io.askimo.core.chat.domain.DirectiveScope import io.askimo.core.chat.dto.ChatMessageDTO import io.askimo.core.chat.dto.FileAttachmentDTO import io.askimo.core.chat.util.FileContentExtractor @@ -146,6 +160,14 @@ private val log = currentFileLogger() * @param onCancelEdit Callback to cancel edit mode * @param sessionId Optional session ID for file attachments * @param placeholder Optional placeholder text + * @param onEnabledServerIdsChange Callback when enabled MCP servers change + * @param onNavigateToMcpSettings Optional callback that opens MCP settings + * @param directives Directives available to the current chat session + * @param activeDirectiveIds IDs of directives currently shaping the next message + * @param onToggleDirective Callback that toggles one directive + * @param onAddDirective Callback that opens the new-directive flow + * @param onEditDirective Callback that opens the editor for a directive + * @param onManageDirectives Optional callback that opens directive management * @param modifier Optional modifier for the component */ @Composable @@ -165,6 +187,12 @@ fun chatInputField( placeholder: String = stringResource("chat.input.placeholder"), onEnabledServerIdsChange: ((Set) -> Unit)? = null, onNavigateToMcpSettings: (() -> Unit)? = null, + directives: List = emptyList(), + activeDirectiveIds: Set = emptySet(), + onToggleDirective: (String) -> Unit = {}, + onAddDirective: () -> Unit = {}, + onEditDirective: (ChatDirective) -> Unit = {}, + onManageDirectives: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val inputFocusRequester = remember { FocusRequester() } @@ -271,13 +299,16 @@ fun chatInputField( // State for resizable text field. val fontScale = LocalFontScale.current val inlineControlsBottomPadding = 44.dp + val hasDirectives = directives.isNotEmpty() + val directiveRowHeight = if (hasDirectives) 40.dp else 0.dp + val inputChromeHeight = inlineControlsBottomPadding + directiveRowHeight val lineHeight = 24.dp * fontScale val padding = 36.dp - val defaultTextFieldHeight = (padding + lineHeight + inlineControlsBottomPadding) + val defaultTextFieldHeight = (padding + lineHeight + inputChromeHeight) .coerceAtLeast(104.dp) // Re-key on fontScale so the height resets when the user changes font size mid-session. - var textFieldHeight by remember(sessionId, fontScale) { mutableStateOf(defaultTextFieldHeight) } - var manuallyResized by remember(sessionId, fontScale) { mutableStateOf(false) } + var textFieldHeight by remember(sessionId, fontScale, hasDirectives) { mutableStateOf(defaultTextFieldHeight) } + var manuallyResized by remember(sessionId, fontScale, hasDirectives) { mutableStateOf(false) } // Track the actual width of the text field for accurate wrapping calculation var textFieldWidthPx by remember { mutableStateOf(0f) } @@ -323,8 +354,8 @@ fun chatInputField( } // Approximate height per line (can be adjusted based on your text style) - val calculatedHeight = (lineHeight * estimatedLineCount) + padding + inlineControlsBottomPadding - val maxVisibleInputLines = ((textFieldHeight - inlineControlsBottomPadding - 1.dp - 32.dp) / lineHeight) + val calculatedHeight = (lineHeight * estimatedLineCount) + padding + inputChromeHeight + val maxVisibleInputLines = ((textFieldHeight - inputChromeHeight - 1.dp - 32.dp) / lineHeight) .toInt() .coerceAtLeast(1) @@ -549,6 +580,7 @@ fun chatInputField( // Custom two-section input container: // ┌─────────────────────────────────────────┐ + // │ Directives chip (when available) │ // │ Text area (weight 1f, scrollable) │ // ├─────────────────────────────────────────┤ // │ Controls row (fixed height) │ @@ -570,6 +602,26 @@ fun chatInputField( .border(containerBorderWidth, containerBorderColor, RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp)), ) { + if (hasDirectives) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(directiveRowHeight) + .padding(start = 12.dp, end = 12.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + directivesChip( + sessionId = sessionId, + directives = directives, + activeDirectiveIds = activeDirectiveIds, + onToggleDirective = onToggleDirective, + onAddDirective = onAddDirective, + onEditDirective = onEditDirective, + onManageDirectives = onManageDirectives, + ) + } + } + // ── Text area ────────────────────────────────────────────────── OutlinedTextField( value = inputText, @@ -900,6 +952,303 @@ fun chatInputField( } } +/** + * Compact session-directive control shown inside the message composer. + */ +@Composable +private fun directivesChip( + sessionId: String?, + directives: List, + activeDirectiveIds: Set, + onToggleDirective: (String) -> Unit, + onAddDirective: () -> Unit, + onEditDirective: (ChatDirective) -> Unit, + onManageDirectives: (() -> Unit)?, +) { + var showPopup by remember(sessionId) { mutableStateOf(false) } + val density = LocalDensity.current + val popupGapPx = with(density) { 8.dp.roundToPx() } + val popupPositionProvider = remember(popupGapPx) { DirectivePopupPositionProvider(popupGapPx) } + + Box { + directivesChipButton(activeCount = activeDirectiveIds.size, onClick = { showPopup = true }) + + if (showPopup) { + directivesPopup( + popupPositionProvider = popupPositionProvider, + directives = directives, + activeDirectiveIds = activeDirectiveIds, + onToggleDirective = onToggleDirective, + onAddDirective = onAddDirective, + onEditDirective = onEditDirective, + onManageDirectives = onManageDirectives, + onDismiss = { showPopup = false }, + ) + } + } +} + +private class DirectivePopupPositionProvider(private val gapPx: Int) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val maxX = (windowSize.width - popupContentSize.width).coerceAtLeast(0) + val preferredX = if (layoutDirection == LayoutDirection.Ltr) { + anchorBounds.left + } else { + anchorBounds.right - popupContentSize.width + } + val x = preferredX.coerceIn(0, maxX) + val above = anchorBounds.top - popupContentSize.height - gapPx + val maxY = (windowSize.height - popupContentSize.height).coerceAtLeast(0) + val y = if (above >= 0) above else (anchorBounds.bottom + gapPx).coerceIn(0, maxY) + return IntOffset(x, y) + } +} + +@Composable +private fun directivesChipButton(activeCount: Int, onClick: () -> Unit) { + val isActive = activeCount > 0 + themedTooltip(text = stringResource("chat.directives.tooltip", activeCount.toString())) { + Surface( + shape = RoundedCornerShape(10.dp), + color = if (isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 2.dp, + modifier = Modifier + .height(28.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .pointerHoverIcon(PointerIcon.Hand), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Text( + text = stringResource("chat.directives.label"), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + if (isActive) Badge { Text(activeCount.toString()) } + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = stringResource("chat.directives.open"), + modifier = Modifier.size(14.dp), + ) + } + } + } +} + +@Composable +private fun directivesPopup( + popupPositionProvider: PopupPositionProvider, + directives: List, + activeDirectiveIds: Set, + onToggleDirective: (String) -> Unit, + onAddDirective: () -> Unit, + onEditDirective: (ChatDirective) -> Unit, + onManageDirectives: (() -> Unit)?, + onDismiss: () -> Unit, +) { + Popup( + popupPositionProvider = popupPositionProvider, + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + Surface( + modifier = Modifier + .widthIn(min = 300.dp, max = 400.dp) + .heightIn(max = 380.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + shadowElevation = 10.dp, + ) { + Column(modifier = Modifier.padding(vertical = 12.dp)) { + directivesPopupHeader() + directivesPopupList( + directives = directives, + activeDirectiveIds = activeDirectiveIds, + onToggleDirective = onToggleDirective, + onEditDirective = { directive -> + onDismiss() + onEditDirective(directive) + }, + modifier = Modifier.weight(1f, fill = false), + ) + directivesPopupFooter(onAddDirective, onManageDirectives, onDismiss) + } + } + } +} + +@Composable +private fun directivesPopupHeader() { + Text( + text = stringResource("chat.directives.popup.title"), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Text( + text = stringResource("chat.directives.popup.description"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +private fun directivesPopupList( + directives: List, + activeDirectiveIds: Set, + onToggleDirective: (String) -> Unit, + onEditDirective: (ChatDirective) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .fillMaxWidth() + .heightIn(max = 260.dp), + ) { + items(directives, key = { it.id }) { directive -> + directivePopupRow( + directive = directive, + isActive = directive.id in activeDirectiveIds, + onToggle = { onToggleDirective(directive.id) }, + onEdit = { onEditDirective(directive) }, + ) + } + } +} + +@Composable +private fun directivePopupRow( + directive: ChatDirective, + isActive: Boolean, + onToggle: () -> Unit, + onEdit: () -> Unit, +) { + themedTooltip(text = directive.content) { + Row( + modifier = Modifier + .fillMaxWidth() + .pointerHoverIcon(PointerIcon.Hand) + .toggleable( + value = isActive, + role = Role.Checkbox, + onValueChange = { onToggle() }, + ) + .padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Checkbox( + checked = isActive, + onCheckedChange = null, + ) + Text( + text = directive.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + directivePopupTrailingAction(directive, onEdit) + } + } +} + +@Composable +private fun directivePopupTrailingAction(directive: ChatDirective, onEdit: () -> Unit) { + if (directive.scope == DirectiveScope.TEAM) { + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + text = stringResource("directive.scope.team"), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + } else { + IconButton( + onClick = onEdit, + modifier = Modifier + .size(32.dp) + .pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource("chat.directives.edit", directive.name), + modifier = Modifier.size(16.dp), + ) + } + } +} + +@Composable +private fun directivesPopupFooter( + onAddDirective: () -> Unit, + onManageDirectives: (() -> Unit)?, + onDismiss: () -> Unit, +) { + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { + onDismiss() + onAddDirective() + }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) { + Icon(imageVector = Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource("chat.directive.new")) + } + if (onManageDirectives != null) { + TextButton( + onClick = { + onDismiss() + onManageDirectives() + }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) { + Text(stringResource("chat.directive.manage")) + } + } + } +} + /** * Tools indicator button that shows available MCP servers and tools. * Displays tool count badge and opens a popup with server/tool hierarchy. 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 0e3f70d3..199fdd90 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 @@ -35,7 +35,7 @@ data class ChatState( val isSearching: Boolean, // Directive state - val selectedDirective: String?, + val activeDirectiveIds: Set, // Session state val sessionTitle: 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 934254e2..74dacb5a 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 @@ -13,27 +13,21 @@ 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.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Search @@ -41,7 +35,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -82,9 +75,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import io.askimo.core.AppConstants.DOMAIN import io.askimo.core.chat.domain.ChatDirective -import io.askimo.core.chat.domain.DirectiveScope import io.askimo.core.chat.domain.Project import io.askimo.core.chat.dto.ChatMessageDTO import io.askimo.core.chat.dto.FileAttachmentDTO @@ -107,14 +98,13 @@ import io.askimo.ui.common.keymap.KeyMapManager import io.askimo.ui.common.keymap.KeyMapManager.AppShortcut import io.askimo.ui.common.preferences.ApplicationPreferences import io.askimo.ui.common.theme.AppComponents -import io.askimo.ui.common.theme.AppComponents.dropdownMenu 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.themedTooltip import io.askimo.ui.common.ui.util.FileDialogUtils import io.askimo.ui.service.AvatarService +import io.askimo.ui.session.editDirectiveDialog import io.askimo.ui.session.manageDirectivesDialog import io.askimo.ui.session.newDirectiveDialog import io.askimo.ui.session.sessionActionsMenu @@ -176,7 +166,7 @@ fun chatView( val searchResults = state.searchResults val currentSearchResultIndex = state.currentSearchResultIndex val isSearching = state.isSearching - val selectedDirective = state.selectedDirective + val activeDirectiveIds = state.activeDirectiveIds val sessionTitle = state.sessionTitle val project = state.project @@ -281,6 +271,8 @@ fun chatView( // Load all directives var availableDirectives by remember { mutableStateOf>(emptyList()) } var showNewDirectiveDialog by remember { mutableStateOf(false) } + var editingDirective by remember { mutableStateOf(null) } + var showManageDirectivesDialog by remember { mutableStateOf(false) } // Session memory dialog state var showSessionMemoryDialog by remember { mutableStateOf(false) } @@ -452,7 +444,7 @@ fun chatView( // Apply to current session if requested if (applyToCurrent) { - actions.setDirective(newDirective.id) + actions.setDirectiveActive(newDirective.id, true) } showNewDirectiveDialog = false @@ -460,6 +452,48 @@ fun chatView( ) } + editingDirective?.let { directive -> + editDirectiveDialog( + initialName = directive.name, + initialContent = directive.content, + onDismiss = { editingDirective = null }, + onConfirm = { newName, newContent -> + directiveService.updateDirective(directive.id, newName, newContent) + availableDirectives = directiveService.listAllDirectives() + editingDirective = null + }, + ) + } + + if (showManageDirectivesDialog) { + manageDirectivesDialog( + directives = availableDirectives, + onDismiss = { showManageDirectivesDialog = false }, + onAdd = { name, content, applyToCurrent -> + val newDirective = directiveService.createDirective(name, content) + availableDirectives = directiveService.listAllDirectives() + if (applyToCurrent) { + actions.setDirectiveActive(newDirective.id, true) + } + }, + onUpdate = { id, newName, newContent -> + directiveService.updateDirective(id, newName, newContent) + availableDirectives = directiveService.listAllDirectives() + }, + onDelete = { id -> + directiveService.deleteDirective(id) + actions.setDirectiveActive(id, false) + availableDirectives = directiveService.listAllDirectives() + }, + onExport = directiveService::exportToJson, + onImport = { json -> + val result = directiveService.importFromJson(json) + availableDirectives = directiveService.listAllDirectives() + result + }, + ) + } + Box( modifier = Modifier .fillMaxSize() @@ -530,7 +564,7 @@ fun chatView( .weight(1f) .fillMaxHeight(), ) { - // Session header with title and directive selector + // Session header with title and session actions if (provider != null && model != null) { Card( modifier = Modifier @@ -661,273 +695,11 @@ fun chatView( } } - // Right side: Directive selector and session actions + // Right side: session actions Row( horizontalArrangement = Arrangement.spacedBy(Spacing.small), verticalAlignment = Alignment.CenterVertically, ) { - var showManageDirectivesDialog by remember { mutableStateOf(false) } - var directiveDropdownExpanded by remember { mutableStateOf(false) } - - // Get the selected directive details - val selectedDirectiveObj = remember(selectedDirective, availableDirectives) { - selectedDirective?.let { id -> - availableDirectives.find { it.id == id } - } - } - - Box { - themedTooltip( - text = if (selectedDirectiveObj != null) { - "${stringResource("chat.directive")}: ${selectedDirectiveObj.name}\n${selectedDirectiveObj.content}" - } else { - "${stringResource("chat.directive")}: ${stringResource("chat.directive.none")}" - }, - ) { - TextButton( - onClick = { directiveDropdownExpanded = true }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - colors = ButtonDefaults.textButtonColors( - contentColor = if (selectedDirectiveObj != null) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ), - ) { - Icon( - imageVector = Icons.Default.AutoAwesome, - contentDescription = stringResource("chat.directive"), - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = selectedDirectiveObj?.name?.take(30)?.let { - if (selectedDirectiveObj.name.length > 30) "$it..." else it - } ?: stringResource("chat.directive.none"), - style = MaterialTheme.typography.bodyMedium, - ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "Select directive", - modifier = Modifier.size(20.dp), - ) - } - } - - dropdownMenu( - expanded = directiveDropdownExpanded, - onDismissRequest = { directiveDropdownExpanded = false }, - modifier = Modifier.fillMaxWidth(0.3f), - ) { - // "None" option to clear directive - DropdownMenuItem( - text = { - Text( - text = stringResource("chat.directive.none"), - style = MaterialTheme.typography.bodyMedium, - ) - }, - onClick = { - actions.setDirective(null) - directiveDropdownExpanded = false - }, - leadingIcon = if (selectedDirective == null) { - { - Icon( - Icons.Default.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } else { - null - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) - - // Show available directives in a scrollable section - if (availableDirectives.isNotEmpty()) { - HorizontalDivider() - - availableDirectives.forEach { directive -> - themedTooltip( - text = "${directive.name}\n${directive.content}", - placement = TooltipPlacement.LEFT, - ) { - DropdownMenuItem( - text = { - Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.small), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = directive.name, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f, fill = false), - ) - if (directive.scope == DirectiveScope.TEAM) { - Surface( - shape = MaterialTheme.shapes.extraSmall, - color = MaterialTheme.colorScheme.tertiaryContainer, - ) { - Text( - text = stringResource("directive.scope.team"), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), - ) - } - } - } - }, - onClick = { - actions.setDirective(directive.id) - directiveDropdownExpanded = false - }, - leadingIcon = if (selectedDirective == directive.id) { - { - Icon( - Icons.Default.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } else { - null - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) - } - } - } - - // Action items section - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - ) - - // New Directive action - DropdownMenuItem( - text = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.Add, - contentDescription = "New directive", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource("chat.directive.new"), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - }, - onClick = { - showNewDirectiveDialog = true - directiveDropdownExpanded = false - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) - - // Manage Directives action - DropdownMenuItem( - text = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.Edit, - contentDescription = "Manage directives", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource("chat.directive.manage"), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - }, - onClick = { - showManageDirectivesDialog = true - directiveDropdownExpanded = false - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) - - // Learn more link - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - ) - val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current - DropdownMenuItem( - text = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp), - ) - Text( - text = stringResource("chat.directive.learn.more"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - onClick = { - uriHandler.openUri("https://$DOMAIN/docs/desktop/directives/") - directiveDropdownExpanded = false - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) - } - - // Show manage directives dialog - if (showManageDirectivesDialog) { - manageDirectivesDialog( - directives = availableDirectives, - onDismiss = { showManageDirectivesDialog = false }, - onAdd = { name, content, applyToCurrent -> - val newDirective = directiveService.createDirective(name, content) - availableDirectives = directiveService.listAllDirectives() - if (applyToCurrent) { - actions.setDirective(newDirective.id) - } - }, - onUpdate = { id, newName, newContent -> - directiveService.updateDirective(id, newName, newContent) - availableDirectives = directiveService.listAllDirectives() - }, - onDelete = { id -> - directiveService.deleteDirective(id) - if (selectedDirective == id) { - actions.setDirective(null) - } - availableDirectives = directiveService.listAllDirectives() - }, - onExport = { - directiveService.exportToJson() - }, - onImport = { json -> - val result = directiveService.importFromJson(json) - availableDirectives = directiveService.listAllDirectives() - result - }, - ) - } - } - if (sessionId != null && messages.isNotEmpty()) { sessionActionsMenu( sessionId = sessionId, @@ -1273,6 +1045,14 @@ fun chatView( sessionId = sessionId, onEnabledServerIdsChange = { currentEnabledServerIds = it }, onNavigateToMcpSettings = onNavigateToMcpSettings, + directives = availableDirectives, + activeDirectiveIds = activeDirectiveIds, + onToggleDirective = { directiveId -> + actions.setDirectiveActive(directiveId, directiveId !in activeDirectiveIds) + }, + onAddDirective = { showNewDirectiveDialog = true }, + onEditDirective = { editingDirective = it }, + onManageDirectives = { showManageDirectivesDialog = true }, modifier = Modifier .widthIn(max = ThemePreferences.CONTENT_MAX_WIDTH) .fillMaxWidth() 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 3ce1dc98..ac0db42b 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 @@ -101,7 +101,7 @@ class ChatViewModel( var isSearchMode by mutableStateOf(false) private set - var selectedDirective by mutableStateOf(null) + var activeDirectiveIds by mutableStateOf>(emptySet()) private set var sessionTitle by mutableStateOf(null) @@ -126,7 +126,7 @@ class ChatViewModel( searchResults = searchResults, currentSearchResultIndex = currentSearchResultIndex, isSearching = isSearching, - selectedDirective = selectedDirective, + activeDirectiveIds = activeDirectiveIds, sessionTitle = sessionTitle ?: "", project = project, ) @@ -526,7 +526,7 @@ class ChatViewModel( // 6. Resend the user message currentJob = scope.launch { try { - if (selectedDirective != null) { + if (activeDirectiveIds.isNotEmpty()) { Analytics.track(AnalyticsEvent.DIRECTIVE_USED) } val threadId = sessionManager.sendMessage( @@ -536,7 +536,7 @@ class ChatViewModel( userMessage = userMessage, willSaveUserMessage = false, enabledServerIds = enabledServerIds, - directiveId = selectedDirective, + directiveIds = activeDirectiveIds, ) if (threadId == null) { @@ -634,7 +634,7 @@ class ChatViewModel( currentJob = scope.launch { try { - if (selectedDirective != null) { + if (activeDirectiveIds.isNotEmpty()) { Analytics.track(AnalyticsEvent.DIRECTIVE_USED) } val threadId = sessionManager.sendMessage( @@ -644,7 +644,7 @@ class ChatViewModel( userMessage = userMessage, willSaveUserMessage = true, enabledServerIds = enabledServerIds, - directiveId = selectedDirective, + directiveIds = activeDirectiveIds, ) if (threadId == null) { @@ -802,8 +802,8 @@ class ChatViewModel( } } - // Load directive from the resumed session - selectedDirective = result.directiveId + // Load active directives from the resumed session + activeDirectiveIds = result.activeDirectiveIds // Load session title and project sessionTitle = result.title @@ -1141,27 +1141,30 @@ class ChatViewModel( // Clear search state clearSearch() - // Reset directive to null for new chat session - selectedDirective = null + // Reset directives for a new chat session + activeDirectiveIds = emptySet() } /** - * Set the directive for the current or next chat session. - * @param directiveId The directive ID to set (null to clear directive) + * Activate or deactivate a directive for the current or next chat session. */ - override fun setDirective(directiveId: String?) { - selectedDirective = directiveId + override fun setDirectiveActive(directiveId: String, active: Boolean) { + activeDirectiveIds = if (active) { + activeDirectiveIds + directiveId + } else { + activeDirectiveIds - directiveId + } val sessionId = currentSessionId.value if (sessionId != null) { scope.launch { try { withContext(Dispatchers.IO) { - chatSessionService.updateSessionDirective(sessionId, directiveId) + chatSessionService.setSessionDirectiveActive(sessionId, directiveId, active) } - log.debug("Updated directive for session $sessionId to $directiveId") + log.debug("Set directive $directiveId active=$active for session $sessionId") } catch (e: Exception) { - log.error("Failed to update session directive: ${e.message}", e) + log.error("Failed to update session directive state: ${e.message}", e) } } } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt index 167cd353..6cbfb248 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt @@ -165,7 +165,7 @@ class SessionManager( userMessage: ChatMessageDTO, willSaveUserMessage: Boolean, enabledServerIds: Set = emptySet(), - directiveId: String? = null, + directiveIds: Set = emptySet(), ): String? { // Create session lazily on first message (only once per session) if (!createdSessions.contains(sessionId)) { @@ -182,10 +182,10 @@ class SessionManager( createdSessions.add(sessionId) log.debug("Created new session: $sessionId") - // Persist the selected directive (if any) to the newly created session - if (directiveId != null) { - chatSessionService.updateSessionDirective(sessionId, directiveId) - log.debug("Applied directive $directiveId to new session $sessionId") + // Persist active directives before the first model request reads them. + if (directiveIds.isNotEmpty()) { + chatSessionService.updateSessionDirectives(sessionId, directiveIds) + log.debug("Applied ${directiveIds.size} directives to new session $sessionId") } } } diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index 31135135..b1ea19d2 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -636,6 +636,12 @@ chat.directive.none=None chat.directive.new=New Directive chat.directive.manage=Manage Directives chat.directive.learn.more=Learn how to use directives +chat.directives.label=Directives +chat.directives.tooltip=Manage directives. {0} active. +chat.directives.open=Open directives +chat.directives.popup.title=Directives for this chat +chat.directives.popup.description=Choose which directives shape your next message. +chat.directives.edit=Edit {0} chat.attach.file=Attach File ({0}+A) chat.attach.file.menu=Add Attachments chat.drop.files=Drop files to attach to the conversation diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index e1f40e94..ebcf61df 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -614,6 +614,12 @@ chat.directive.none=Keine chat.directive.new=Neue Direktive chat.directive.manage=Direktiven verwalten chat.directive.learn.more=Erfahren Sie, wie Sie Direktiven verwenden +chat.directives.label=Direktiven +chat.directives.tooltip=Direktiven verwalten. {0} aktiv. +chat.directives.open=Direktiven öffnen +chat.directives.popup.title=Direktiven für diesen Chat +chat.directives.popup.description=Wählen Sie aus, welche Direktiven Ihre nächste Nachricht beeinflussen. +chat.directives.edit={0} bearbeiten chat.attach.file=Datei anhängen ({0}+A) chat.attach.file.menu=Anhänge hinzufügen chat.drop.files=Dateien zum Anhängen an die Unterhaltung hierher ziehen diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index b03537ff..da13ab30 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -613,6 +613,12 @@ chat.directive.none=Ninguna chat.directive.new=Nueva directiva chat.directive.manage=Gestionar directivas chat.directive.learn.more=Aprenda a usar directivas +chat.directives.label=Directivas +chat.directives.tooltip=Gestionar directivas. {0} activas. +chat.directives.open=Abrir directivas +chat.directives.popup.title=Directivas para este chat +chat.directives.popup.description=Elige qué directivas darán forma a tu próximo mensaje. +chat.directives.edit=Editar {0} chat.attach.file=Adjuntar archivo ({0}+A) chat.attach.file.menu=Agregar archivos adjuntos chat.drop.files=Arrastra archivos para adjuntarlos a la conversación diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index c535d7ed..2862115c 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -613,6 +613,12 @@ chat.directive.none=Aucune chat.directive.new=Nouvelle directive chat.directive.manage=Gérer les directives chat.directive.learn.more=Apprenez à utiliser les directives +chat.directives.label=Directives +chat.directives.tooltip=Gérer les directives. {0} actives. +chat.directives.open=Ouvrir les directives +chat.directives.popup.title=Directives pour cette discussion +chat.directives.popup.description=Choisissez les directives qui guideront votre prochain message. +chat.directives.edit=Modifier {0} chat.attach.file=Joindre un fichier ({0}+A) chat.attach.file.menu=Ajouter des pièces jointes chat.drop.files=Glissez-déposez des fichiers pour les joindre à la conversation 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 f91bd3e7..c685c0a1 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -612,6 +612,12 @@ chat.directive.none=なし chat.directive.new=新しいディレクティブ chat.directive.manage=ディレクティブ管理 chat.directive.learn.more=ディレクティブの使い方を学ぶ +chat.directives.label=ディレクティブ +chat.directives.tooltip=ディレクティブを管理します。{0} 件が有効です。 +chat.directives.open=ディレクティブを開く +chat.directives.popup.title=このチャットのディレクティブ +chat.directives.popup.description=次のメッセージに適用するディレクティブを選択します。 +chat.directives.edit={0}を編集 chat.attach.file=ファイル添付 ({0}+A) chat.attach.file.menu=添付ファイルを追加 chat.drop.files=ファイルをドラッグ&ドロップして会話に添付 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 ae40fb2f..64c343ab 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -613,6 +613,12 @@ chat.directive.none=없음 chat.directive.new=새 지시문 chat.directive.manage=지시문 관리 chat.directive.learn.more=디렉티브 사용법을 배워보세요 +chat.directives.label=지시문 +chat.directives.tooltip=지시문을 관리합니다. {0}개 활성화됨. +chat.directives.open=지시문 열기 +chat.directives.popup.title=이 채팅의 지시문 +chat.directives.popup.description=다음 메시지에 적용할 지시문을 선택하세요. +chat.directives.edit={0} 편집 chat.attach.file=파일 첨부 ({0}+A) chat.attach.file.menu=첨부 파일 추가 chat.drop.files=파일을 드래그하여 대화에 첨부하세요 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 b49013df..e99a96fb 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -615,6 +615,12 @@ chat.directive.none=Nenhuma chat.directive.new=Nova Diretiva chat.directive.manage=Gerenciar Diretivas chat.directive.learn.more=Aprenda a usar diretivas +chat.directives.label=Diretivas +chat.directives.tooltip=Gerenciar diretivas. {0} ativas. +chat.directives.open=Abrir diretivas +chat.directives.popup.title=Diretivas para este chat +chat.directives.popup.description=Escolha quais diretivas orientam sua próxima mensagem. +chat.directives.edit=Editar {0} chat.attach.file=Anexar Arquivo ({0}+A) chat.attach.file.menu=Adicionar Anexos chat.drop.files=Arraste arquivos para anexar à conversa 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 6ea05c9b..bb49be7b 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -620,6 +620,12 @@ chat.directive.none=Không có chat.directive.new=Chỉ thị mới chat.directive.manage=Quản lý chỉ thị chat.directive.learn.more=Tìm hiểu cách sử dụng chỉ thị +chat.directives.label=Chỉ thị +chat.directives.tooltip=Quản lý chỉ thị. Đang bật {0}. +chat.directives.open=Mở chỉ thị +chat.directives.popup.title=Chỉ thị cho cuộc trò chuyện này +chat.directives.popup.description=Chọn các chỉ thị định hướng tin nhắn tiếp theo của bạn. +chat.directives.edit=Chỉnh sửa {0} chat.attach.file=Đính kèm tệp ({0}+A) chat.attach.file.menu=Thêm tệp đính kèm chat.drop.files=Kéo thả tệp để đính kèm vào cuộc trò chuyện 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 bce53fb2..ccfd02bd 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -613,6 +613,12 @@ chat.directive.none=无 chat.directive.new=新指令 chat.directive.manage=管理指令 chat.directive.learn.more=了解如何使用指令 +chat.directives.label=指令 +chat.directives.tooltip=管理指令。已启用 {0} 个。 +chat.directives.open=打开指令 +chat.directives.popup.title=此聊天的指令 +chat.directives.popup.description=选择用于引导下一条消息的指令。 +chat.directives.edit=编辑 {0} chat.attach.file=附加文件 ({0}+A) chat.attach.file.menu=添加附件 chat.drop.files=拖拽文件以将其附加到对话中 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 dd27c9b3..1ef46b39 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -612,6 +612,12 @@ chat.directive.none=無 chat.directive.new=新增指令 chat.directive.manage=管理指令 chat.directive.learn.more=了解如何使用指令 +chat.directives.label=指令 +chat.directives.tooltip=管理指令。已啟用 {0} 個。 +chat.directives.open=開啟指令 +chat.directives.popup.title=此聊天的指令 +chat.directives.popup.description=選擇用來引導下一則訊息的指令。 +chat.directives.edit=編輯 {0} chat.attach.file=附加檔案 ({0}+A) chat.attach.file.menu=新增附件 chat.drop.files=拖曳檔案以將其附加到對話中 diff --git a/shared/src/main/kotlin/io/askimo/core/chat/domain/ChatSession.kt b/shared/src/main/kotlin/io/askimo/core/chat/domain/ChatSession.kt index cbf5ef87..d4ffc949 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/domain/ChatSession.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/domain/ChatSession.kt @@ -44,3 +44,22 @@ object ChatSessionsTable : Table("chat_sessions") { foreignKey(projectId to ProjectsTable.id, onDelete = ReferenceOption.CASCADE) } } + +/** + * Many-to-many association between chat sessions and their active directives. + * + * [ChatSessionsTable.directiveId] remains as a compatibility projection for older + * databases and sync payloads, while this table is the source of truth for the + * complete active directive set. + */ +object ChatSessionDirectivesTable : Table("chat_session_directives") { + val sessionId = varchar("session_id", 36) + val directiveId = varchar("directive_id", 36) + + override val primaryKey = PrimaryKey(sessionId, directiveId) + + init { + foreignKey(sessionId to ChatSessionsTable.id, onDelete = ReferenceOption.CASCADE) + foreignKey(directiveId to ChatDirectivesTable.id, onDelete = ReferenceOption.CASCADE) + } +} diff --git a/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatDirectiveRepository.kt b/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatDirectiveRepository.kt index b91961d4..4573a434 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatDirectiveRepository.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatDirectiveRepository.kt @@ -10,6 +10,7 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import com.fasterxml.jackson.module.kotlin.KotlinModule import io.askimo.core.chat.domain.ChatDirective import io.askimo.core.chat.domain.ChatDirectivesTable +import io.askimo.core.chat.domain.ChatSessionDirectivesTable import io.askimo.core.chat.domain.ChatSessionsTable import io.askimo.core.chat.domain.DIRECTIVE_CONTENT_MAX_LENGTH import io.askimo.core.chat.domain.DIRECTIVE_NAME_MAX_LENGTH @@ -135,9 +136,7 @@ class ChatDirectiveRepository internal constructor( * Delete a directive by id. * @return true if deleted, false if directive doesn't exist */ - fun delete(id: String): Boolean = transaction(database) { - ChatDirectivesTable.deleteWhere { ChatDirectivesTable.id eq id } > 0 - } + fun delete(id: String): Boolean = deleteAndRefreshSessionProjections(id) /** * Check if a directive exists by id. @@ -185,6 +184,39 @@ class ChatDirectiveRepository internal constructor( ?.toChatDirective() } + /** + * Find every active directive for a session, ordered by name for deterministic + * system-message construction. + */ + fun findDirectivesBySessionId(sessionId: String): List = transaction(database) { + val activeDirectives = ChatDirectivesTable + .join( + ChatSessionDirectivesTable, + JoinType.INNER, + ChatDirectivesTable.id, + ChatSessionDirectivesTable.directiveId, + ) + .selectAll() + .where { ChatSessionDirectivesTable.sessionId eq sessionId } + .orderBy(ChatDirectivesTable.name to SortOrder.ASC) + .map { it.toChatDirective() } + + if (activeDirectives.isNotEmpty()) { + activeDirectives + } else { + // Compatibility fallback for older synchronized sessions that only + // contain chat_sessions.directive_id. + ChatDirectivesTable + .join(ChatSessionsTable, JoinType.INNER, ChatDirectivesTable.id, ChatSessionsTable.directiveId) + .selectAll() + .where { ChatSessionsTable.id eq sessionId } + .singleOrNull() + ?.toChatDirective() + ?.let(::listOf) + .orEmpty() + } + } + /** * Returns all PERSONAL directives whose [syncedAt] is NULL or older than [updatedAt], * meaning they have local changes that have not yet been pushed to the server. @@ -262,6 +294,41 @@ class ChatDirectiveRepository internal constructor( } } } + + reconcilePendingSessionRelations(ids) + } + } + + /** + * Restore legacy session selections whose directives arrived after their sessions. + * The legacy column remains available while the directive is missing, allowing this + * reconciliation to preserve any relations the user activated in the meantime. + */ + private fun reconcilePendingSessionRelations(directiveIds: List) { + val pendingSessions = ChatSessionsTable + .selectAll() + .where { ChatSessionsTable.directiveId inList directiveIds } + .mapNotNull { row -> + row[ChatSessionsTable.directiveId]?.let { directiveId -> + row[ChatSessionsTable.id] to directiveId + } + } + + pendingSessions.forEach { (sessionId, legacyDirectiveId) -> + ChatSessionDirectivesTable.upsert { + it[ChatSessionDirectivesTable.sessionId] = sessionId + it[ChatSessionDirectivesTable.directiveId] = legacyDirectiveId + } + + val projectedDirectiveId = ChatSessionDirectivesTable + .selectAll() + .where { ChatSessionDirectivesTable.sessionId eq sessionId } + .orderBy(ChatSessionDirectivesTable.directiveId, SortOrder.ASC) + .limit(1) + .single()[ChatSessionDirectivesTable.directiveId] + ChatSessionsTable.update({ ChatSessionsTable.id eq sessionId }) { + it[directiveId] = projectedDirectiveId + } } } @@ -269,8 +336,36 @@ class ChatDirectiveRepository internal constructor( * Permanently removes a directive from the local database. * Called when the server signals that a directive has been soft-deleted. */ - fun hardDelete(directiveId: String): Boolean = transaction(database) { - ChatDirectivesTable.deleteWhere { ChatDirectivesTable.id eq directiveId } > 0 + fun hardDelete(directiveId: String): Boolean = deleteAndRefreshSessionProjections(directiveId) + + private fun deleteAndRefreshSessionProjections(directiveId: String): Boolean = transaction(database) { + val affectedSessionIds = buildSet { + ChatSessionDirectivesTable + .selectAll() + .where { ChatSessionDirectivesTable.directiveId eq directiveId } + .mapTo(this) { it[ChatSessionDirectivesTable.sessionId] } + ChatSessionsTable + .selectAll() + .where { ChatSessionsTable.directiveId eq directiveId } + .mapTo(this) { it[ChatSessionsTable.id] } + } + + val deleted = ChatDirectivesTable.deleteWhere { ChatDirectivesTable.id eq directiveId } > 0 + if (deleted) { + affectedSessionIds.forEach { sessionId -> + val remainingDirectiveId = ChatSessionDirectivesTable + .selectAll() + .where { ChatSessionDirectivesTable.sessionId eq sessionId } + .map { it[ChatSessionDirectivesTable.directiveId] } + .minOrNull() + ChatSessionsTable.update({ ChatSessionsTable.id eq sessionId }) { + it[ChatSessionsTable.directiveId] = remainingDirectiveId + it[ChatSessionsTable.updatedAt] = Instant.now() + it[ChatSessionsTable.syncedAt] = null + } + } + } + deleted } /** diff --git a/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatSessionRepository.kt b/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatSessionRepository.kt index bb49c901..3f9f1ba5 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatSessionRepository.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatSessionRepository.kt @@ -4,7 +4,9 @@ */ package io.askimo.core.chat.repository +import io.askimo.core.chat.domain.ChatDirectivesTable import io.askimo.core.chat.domain.ChatSession +import io.askimo.core.chat.domain.ChatSessionDirectivesTable import io.askimo.core.chat.domain.ChatSessionsTable import io.askimo.core.chat.domain.SESSION_TITLE_MAX_LENGTH import io.askimo.core.db.AbstractSQLiteRepository @@ -29,6 +31,7 @@ import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.update +import org.jetbrains.exposed.v1.jdbc.upsert import java.time.Instant import java.util.UUID @@ -72,6 +75,12 @@ class ChatSessionRepository internal constructor( it[ChatSessionsTable.directiveId] = sessionWithInjectedFields.directiveId it[ChatSessionsTable.isStarred] = if (sessionWithInjectedFields.isStarred) 1 else 0 } + sessionWithInjectedFields.directiveId?.let { directiveId -> + ChatSessionDirectivesTable.upsert { + it[sessionId] = sessionWithInjectedFields.id + it[ChatSessionDirectivesTable.directiveId] = directiveId + } + } } EventBus.post(PushDataToServerEvent(reason = "session created")) @@ -254,18 +263,147 @@ class ChatSessionRepository internal constructor( return title } + /** Compatibility helper that replaces the active set with zero or one directive. */ + fun updateSessionDirective(sessionId: String, directiveId: String?): Boolean = transaction(database) { + replaceSessionDirectivesInTransaction(sessionId, setOfNotNull(directiveId)) + }.also { if (it) EventBus.post(PushDataToServerEvent(reason = "session directives changed")) } + /** - * Update the directive for a chat session. - * @param sessionId The session ID - * @param directiveId The directive ID to set (null to clear directive) - * @return true if updated successfully + * Return all active directive IDs for a session. */ - fun updateSessionDirective(sessionId: String, directiveId: String?): Boolean = transaction(database) { + fun getActiveDirectiveIds(sessionId: String): Set = transaction(database) { + val activeIds = ChatSessionDirectivesTable + .selectAll() + .where { ChatSessionDirectivesTable.sessionId eq sessionId } + .orderBy(ChatSessionDirectivesTable.directiveId, SortOrder.ASC) + .mapTo(linkedSetOf()) { it[ChatSessionDirectivesTable.directiveId] } + + if (activeIds.isNotEmpty()) { + activeIds + } else { + // Compatibility fallback for sessions pulled from an older sync payload. + ChatSessionsTable + .selectAll() + .where { ChatSessionsTable.id eq sessionId } + .singleOrNull() + ?.get(ChatSessionsTable.directiveId) + ?.let(::setOf) + .orEmpty() + } + } + + /** + * Replace the complete active directive set for a session atomically. + */ + fun replaceSessionDirectives(sessionId: String, directiveIds: Set): Boolean = transaction(database) { + replaceSessionDirectivesInTransaction(sessionId, directiveIds) + }.also { if (it) EventBus.post(PushDataToServerEvent(reason = "session directives changed")) } + + /** + * Activate or deactivate one directive without replacing other selections. + */ + fun setSessionDirectiveActive(sessionId: String, directiveId: String, active: Boolean): Boolean = transaction(database) { + val sessionExists = ChatSessionsTable + .selectAll() + .where { ChatSessionsTable.id eq sessionId } + .any() + if (!sessionExists) { + false + } else { + val hasUnresolvedLegacyDirective = populateMissingRelationFromLegacy(sessionId) + if (active) { + ChatSessionDirectivesTable.upsert { + it[ChatSessionDirectivesTable.sessionId] = sessionId + it[ChatSessionDirectivesTable.directiveId] = directiveId + } + } else { + ChatSessionDirectivesTable.deleteWhere { + (ChatSessionDirectivesTable.sessionId eq sessionId) and + (ChatSessionDirectivesTable.directiveId eq directiveId) + } + } + + if (hasUnresolvedLegacyDirective) { + // Keep the unresolved legacy ID until its directive arrives so it can + // still be migrated into the relation table. The toggle is still a + // local session change, so advance updatedAt without replacing it. + ChatSessionsTable.update({ ChatSessionsTable.id eq sessionId }) { + it[updatedAt] = Instant.now() + } + } else { + updateLegacyDirectiveProjection(sessionId) + } + true + } + }.also { if (it) EventBus.post(PushDataToServerEvent(reason = "session directives changed")) } + + /** + * Populate the legacy directive relation if it is missing. + * + * @return true when the legacy ID cannot be linked yet because its directive + * has not arrived locally. Callers must preserve that ID in chat_sessions. + */ + private fun populateMissingRelationFromLegacy(sessionId: String): Boolean { + val legacyDirectiveId = ChatSessionsTable + .selectAll() + .where { ChatSessionsTable.id eq sessionId } + .singleOrNull() + ?.get(ChatSessionsTable.directiveId) + ?: return false + val alreadyLinked = ChatSessionDirectivesTable + .selectAll() + .where { + ChatSessionDirectivesTable.sessionId eq sessionId and + (ChatSessionDirectivesTable.directiveId eq legacyDirectiveId) + } + .any() + if (alreadyLinked) return false + + val directiveExists = ChatDirectivesTable + .selectAll() + .where { ChatDirectivesTable.id eq legacyDirectiveId } + .any() + if (!directiveExists) return true + + ChatSessionDirectivesTable.insert { + it[ChatSessionDirectivesTable.sessionId] = sessionId + it[ChatSessionDirectivesTable.directiveId] = legacyDirectiveId + } + return false + } + + private fun replaceSessionDirectivesInTransaction(sessionId: String, directiveIds: Set): Boolean { + val sessionExists = ChatSessionsTable + .selectAll() + .where { ChatSessionsTable.id eq sessionId } + .any() + if (!sessionExists) return false + + ChatSessionDirectivesTable.deleteWhere { ChatSessionDirectivesTable.sessionId eq sessionId } + directiveIds.forEach { directiveId -> + ChatSessionDirectivesTable.insert { + it[ChatSessionDirectivesTable.sessionId] = sessionId + it[ChatSessionDirectivesTable.directiveId] = directiveId + } + } + updateLegacyDirectiveProjection(sessionId) + return true + } + + private fun updateLegacyDirectiveProjection(sessionId: String) { + val legacyDirectiveId = ChatSessionDirectivesTable + .selectAll() + .where { ChatSessionDirectivesTable.sessionId eq sessionId } + .orderBy(ChatSessionDirectivesTable.directiveId, SortOrder.ASC) + .limit(1) + .singleOrNull() + ?.get(ChatSessionDirectivesTable.directiveId) + ChatSessionsTable.update({ ChatSessionsTable.id eq sessionId }) { - it[ChatSessionsTable.directiveId] = directiveId + it[directiveId] = legacyDirectiveId it[updatedAt] = Instant.now() - } > 0 - }.also { if (it) EventBus.post(PushDataToServerEvent(reason = "session directive changed")) } + } + } /** * Delete a chat session. @@ -483,6 +621,7 @@ class ChatSessionRepository internal constructor( it[isStarred] = if (session.isStarred) 1 else 0 it[syncedAt] = nowStr } + replaceRelationsFromLegacyProjection(session) log.debug("upsertFromServer: inserted session {}", session.id) } else if (session.updatedAt.isAfter(storedUpdatedAt)) { // Server version is newer — overwrite @@ -494,6 +633,7 @@ class ChatSessionRepository internal constructor( it[isStarred] = if (session.isStarred) 1 else 0 it[syncedAt] = nowStr } + replaceRelationsFromLegacyProjection(session) log.debug("upsertFromServer: updated session {} (server newer)", session.id) } else { log.debug("upsertFromServer: skipped session {} (local is same age or newer)", session.id) @@ -502,6 +642,21 @@ class ChatSessionRepository internal constructor( } } + private fun replaceRelationsFromLegacyProjection(session: ChatSession) { + ChatSessionDirectivesTable.deleteWhere { ChatSessionDirectivesTable.sessionId eq session.id } + val directiveId = session.directiveId ?: return + val directiveExists = ChatDirectivesTable + .selectAll() + .where { ChatDirectivesTable.id eq directiveId } + .any() + if (directiveExists) { + ChatSessionDirectivesTable.insert { + it[sessionId] = session.id + it[ChatSessionDirectivesTable.directiveId] = directiveId + } + } + } + /** * Mark a session as successfully synced to the server by setting [syncedAt] * to the current timestamp. diff --git a/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionExporterService.kt b/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionExporterService.kt index d74d74bb..0fa6ce15 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionExporterService.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionExporterService.kt @@ -217,12 +217,13 @@ class ChatSessionExporterService( return try { val session = sessionRepository.getSession(sessionId) ?: return Result.failure(Exception("Session not found: $sessionId")) + val activeDirectiveIds = sessionRepository.getActiveDirectiveIds(sessionId) val file = File(filename) file.parentFile?.mkdirs() file.bufferedWriter().use { writer -> - writeMarkdownHeader(writer, session) + writeMarkdownHeader(writer, session, activeDirectiveIds) streamMessagesToMarkdownFile(writer, sessionId) writeMarkdownFooter(writer) } @@ -248,12 +249,13 @@ class ChatSessionExporterService( return try { val session = sessionRepository.getSession(sessionId) ?: return Result.failure(Exception("Session not found: $sessionId")) + val activeDirectiveIds = sessionRepository.getActiveDirectiveIds(sessionId) val file = File(filename) file.parentFile?.mkdirs() file.bufferedWriter().use { writer -> - writeJsonHeader(writer, session) + writeJsonHeader(writer, session, activeDirectiveIds) streamMessagesToJsonFile(writer, sessionId) writeJsonFooter(writer) } @@ -279,12 +281,13 @@ class ChatSessionExporterService( return try { val session = sessionRepository.getSession(sessionId) ?: return Result.failure(Exception("Session not found: $sessionId")) + val activeDirectiveIds = sessionRepository.getActiveDirectiveIds(sessionId) val file = File(filename) file.parentFile?.mkdirs() file.bufferedWriter().use { writer -> - writeHtmlHeader(writer, session) + writeHtmlHeader(writer, session, activeDirectiveIds) streamMessagesToHtmlFile(writer, sessionId) writeHtmlFooter(writer) } @@ -301,15 +304,20 @@ class ChatSessionExporterService( * * @param writer The buffered writer to write to * @param session The chat session metadata + * @param activeDirectiveIds IDs of all active session directives */ - private fun writeJsonHeader(writer: BufferedWriter, session: ChatSession) { + private fun writeJsonHeader(writer: BufferedWriter, session: ChatSession, activeDirectiveIds: Set) { + val sortedDirectiveIds = activeDirectiveIds.sorted() + val directiveIdJson = sortedDirectiveIds.firstOrNull()?.let { "\"${escapeJson(it)}\"" } ?: "null" + val directiveIdsJson = sortedDirectiveIds.joinToString(", ") { "\"${escapeJson(it)}\"" } writer.apply { appendLine("{") appendLine(" \"sessionId\": \"${escapeJson(session.id)}\",") appendLine(" \"title\": \"${escapeJson(session.title)}\",") appendLine(" \"createdAt\": \"${session.createdAt.atOffset(ZoneOffset.UTC).format(timestampFormatter)}\",") appendLine(" \"lastUpdated\": \"${session.updatedAt.atOffset(java.time.ZoneOffset.UTC).format(timestampFormatter)}\",") - appendLine(" \"directiveId\": ${if (session.directiveId != null) "\"${escapeJson(session.directiveId)}\"" else "null"},") + appendLine(" \"directiveId\": $directiveIdJson,") + appendLine(" \"directiveIds\": [$directiveIdsJson],") appendLine(" \"messages\": [") } } @@ -404,15 +412,16 @@ class ChatSessionExporterService( * * @param writer The buffered writer to write to * @param session The chat session metadata + * @param activeDirectiveIds IDs of all active session directives */ - private fun writeMarkdownHeader(writer: BufferedWriter, session: ChatSession) { + private fun writeMarkdownHeader(writer: BufferedWriter, session: ChatSession, activeDirectiveIds: Set) { writer.appendLine("# Chat Session: ${session.title}") writer.appendLine() writer.appendLine("**Session ID**: ${session.id}") writer.appendLine("**Created**: ${session.createdAt.atOffset(ZoneOffset.UTC).format(timestampFormatter)}") writer.appendLine("**Last Updated**: ${session.updatedAt.atOffset(java.time.ZoneOffset.UTC).format(timestampFormatter)}") - if (session.directiveId != null) { - writer.appendLine("**Directive**: ${session.directiveId}") + if (activeDirectiveIds.isNotEmpty()) { + writer.appendLine("**Directives**: ${activeDirectiveIds.sorted().joinToString()}") } writer.appendLine() writer.appendLine("---") @@ -485,23 +494,25 @@ class ChatSessionExporterService( * * @param writer The buffered writer to write to * @param session The chat session metadata + * @param activeDirectiveIds IDs of all active session directives */ - private fun writeHtmlHeader(writer: BufferedWriter, session: ChatSession) { - writer.appendLine(buildHtmlDocumentStart(session)) + private fun writeHtmlHeader(writer: BufferedWriter, session: ChatSession, activeDirectiveIds: Set) { + writer.appendLine(buildHtmlDocumentStart(session, activeDirectiveIds)) } /** * Build the HTML document start (DOCTYPE, head, and header section). * * @param session The chat session metadata + * @param activeDirectiveIds IDs of all active session directives * @return The HTML string for document start */ - private fun buildHtmlDocumentStart(session: ChatSession): String = buildString { + private fun buildHtmlDocumentStart(session: ChatSession, activeDirectiveIds: Set): String = buildString { appendLine(HTML_DOCTYPE) appendLine(HTML_OPEN) appendLine(buildHtmlHead(session.title)) appendLine(BODY_OPEN) - append(buildHeaderDiv(session)) + append(buildHeaderDiv(session, activeDirectiveIds)) } /** @@ -523,17 +534,18 @@ class ChatSessionExporterService( * Build the header div HTML with session metadata. * * @param session The chat session metadata + * @param activeDirectiveIds IDs of all active session directives * @return The HTML string for the header section */ - private fun buildHeaderDiv(session: ChatSession): String = buildString { + private fun buildHeaderDiv(session: ChatSession, activeDirectiveIds: Set): String = buildString { appendLine("
") appendLine("

${escapeHtml(session.title)}

") appendLine("
") appendLine("

Session ID: ${session.id}

") appendLine("

Created: ${session.createdAt.atOffset(ZoneOffset.UTC).format(timestampFormatter)}

") appendLine("

Last Updated: ${session.updatedAt.atOffset(java.time.ZoneOffset.UTC).format(timestampFormatter)}

") - if (session.directiveId != null) { - appendLine("

Directive: ${escapeHtml(session.directiveId)}

") + if (activeDirectiveIds.isNotEmpty()) { + appendLine("

Directives: ${escapeHtml(activeDirectiveIds.sorted().joinToString())}

") } appendLine("
") appendLine("
") 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 fc46ca47..328f6f92 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 @@ -78,13 +78,17 @@ data class ResumeSessionPaginatedResult( val success: Boolean, val sessionId: String, val title: String? = null, - val directiveId: String?, + val activeDirectiveIds: Set = emptySet(), val project: Project? = null, val messages: List = emptyList(), val cursor: Instant? = null, val hasMore: Boolean = false, val errorMessage: String? = null, -) +) { + /** Compatibility accessor for callers that still expect one directive. */ + val directiveId: String? + get() = activeDirectiveIds.minOrNull() +} /** * Service for managing chat sessions with common logic shared between CLI and desktop. @@ -411,15 +415,18 @@ class ChatSessionService( */ fun renameTitle(sessionId: String, newTitle: String): Boolean = sessionRepository.updateSessionTitle(sessionId, newTitle) - /** - * Update the directive for a chat session. - * - * @param sessionId The ID of the session to update - * @param directiveId The directive ID to set (null to clear directive) - * @return true if the session was updated, false if it didn't exist - */ + /** Compatibility helper that replaces the active set with zero or one directive. */ fun updateSessionDirective(sessionId: String, directiveId: String?): Boolean = sessionRepository.updateSessionDirective(sessionId, directiveId) + /** Replace every active directive for a session. */ + fun updateSessionDirectives(sessionId: String, directiveIds: Set): Boolean = sessionRepository.replaceSessionDirectives(sessionId, directiveIds) + + /** Activate or deactivate one directive for a session. */ + fun setSessionDirectiveActive(sessionId: String, directiveId: String, active: Boolean): Boolean = sessionRepository.setSessionDirectiveActive(sessionId, directiveId, active) + + /** Return all active directive IDs for a session. */ + fun getActiveDirectiveIds(sessionId: String): Set = sessionRepository.getActiveDirectiveIds(sessionId) + /** * Add a message to a session and update the session's timestamp. * @@ -581,7 +588,7 @@ class ChatSessionService( success = true, sessionId = sessionId, title = existingSession.title, - directiveId = existingSession.directiveId, + activeDirectiveIds = sessionRepository.getActiveDirectiveIds(sessionId), project = project, messages = messages.toDTOs(), cursor = cursor, @@ -592,7 +599,7 @@ class ChatSessionService( success = true, sessionId = sessionId, title = null, - directiveId = null, + activeDirectiveIds = emptySet(), project = null, messages = emptyList(), cursor = null, 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 31b98841..6baff50d 100644 --- a/shared/src/main/kotlin/io/askimo/core/db/DatabaseManager.kt +++ b/shared/src/main/kotlin/io/askimo/core/db/DatabaseManager.kt @@ -105,6 +105,7 @@ class DatabaseManager private constructor( createAttachmentsTable(connection) createSummariesTable(connection) createDirectivesTable(connection) + createSessionDirectivesTable(connection) createSessionMemoryTable(connection) createUserMemoryTable(connection) createFileSegmentsTable(connection) @@ -477,6 +478,42 @@ class DatabaseManager private constructor( } } + private fun createSessionDirectivesTable(conn: Connection) { + conn.createStatement().use { stmt -> + stmt.execute("PRAGMA foreign_keys = ON") + stmt.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS chat_session_directives ( + session_id TEXT NOT NULL, + directive_id TEXT NOT NULL, + PRIMARY KEY (session_id, directive_id), + FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (directive_id) REFERENCES chat_directives(id) ON DELETE CASCADE + ) + """.trimIndent(), + ) + stmt.executeUpdate( + """ + CREATE INDEX IF NOT EXISTS idx_session_directives_directive + ON chat_session_directives (directive_id) + """.trimIndent(), + ) + + // Migrate the legacy single directive selection. INSERT OR IGNORE makes + // this safe on every startup, while regular updates keep directive_id in + // sync with the active set so disabled directives are not resurrected. + stmt.executeUpdate( + """ + INSERT OR IGNORE INTO chat_session_directives (session_id, directive_id) + SELECT sessions.id, sessions.directive_id + FROM chat_sessions sessions + INNER JOIN chat_directives directives ON directives.id = sessions.directive_id + WHERE sessions.directive_id IS NOT NULL + """.trimIndent(), + ) + } + } + private fun createSessionMemoryTable(conn: Connection) { conn.createStatement().use { stmt -> stmt.executeUpdate( diff --git a/shared/src/main/kotlin/io/askimo/core/providers/ChatRequestTransformers.kt b/shared/src/main/kotlin/io/askimo/core/providers/ChatRequestTransformers.kt index ec98e60e..7a06c8a9 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/ChatRequestTransformers.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/ChatRequestTransformers.kt @@ -18,7 +18,6 @@ import io.askimo.core.logging.logger */ object ChatRequestTransformers { - private val directiveRepository by lazy { DatabaseManager.getInstance().getChatDirectiveRepository() } private val log = logger() /** @@ -93,12 +92,11 @@ object ChatRequestTransformers { } if (sessionId != null) { - val directive = directiveRepository.findDirectiveBySessionId(sessionId) - if (directive != null && - directive.content.isNotBlank() && - directive.content !in existingSystemMessageTexts - ) { - additionalSystemMessages.add(SystemMessage.from(directive.content)) + val addedDirectiveTexts = existingSystemMessageTexts.toMutableSet() + DatabaseManager.getInstance().getChatDirectiveRepository().findDirectivesBySessionId(sessionId).forEach { directive -> + if (directive.content.isNotBlank() && addedDirectiveTexts.add(directive.content)) { + additionalSystemMessages.add(SystemMessage.from(directive.content)) + } } }