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 2bdfee1bd..88a71c037 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 @@ -313,6 +313,14 @@ fun chatInputField( // Session-scoped web-search-in-RAG toggle. Resets when the session changes. var webSearchInRag by remember(sessionId) { mutableStateOf(false) } + // Cache web-search enabled flag — AppConfig.webSearch hits the OS keychain on every + // call (SecureKeyManager.retrieveSecretKey). Read off the UI thread so the first + // composition is never blocked (avoids navigation lag into ProjectView). + var webSearchEnabled by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + webSearchEnabled = withContext(Dispatchers.IO) { AppConfig.webSearch.enabled } + } + // Notify caller whenever the user changes the enabled server selection. LaunchedEffect(enabledServerIds) { onEnabledServerIdsChange?.invoke(enabledServerIds) @@ -789,7 +797,7 @@ fun chatInputField( ) // ── Web search in RAG chip — only in project sessions when web search is configured ── - if (isProjectSession && AppConfig.webSearch.enabled) { + if (isProjectSession && webSearchEnabled) { Spacer(modifier = Modifier.width(4.dp)) webSearchRagChip( active = webSearchInRag, diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt index e43c88f23..cbdff49b7 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt @@ -791,6 +791,7 @@ object AppComponents { modifier = Modifier .fillMaxWidth() .padding(horizontal = contentPadding) + .padding(top = sectionSpacing, bottom = sectionSpacing) .padding(end = dialogScrollbarPadding) .verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(sectionSpacing), diff --git a/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectView.kt b/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectView.kt index 13400c952..6e972ed14 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectView.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectView.kt @@ -128,11 +128,6 @@ fun projectView( } // Local UI state - var inputText by remember { mutableStateOf(TextFieldValue("")) } - var attachments by remember { mutableStateOf>(emptyList()) } - var currentEnabledServerIds by remember { mutableStateOf(emptySet()) } - var selectedDirective by remember { mutableStateOf(null) } - var webSearchInRag by remember { mutableStateOf(false) } var showProjectMenu by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } var showReIndexConfirmDialog by remember { mutableStateOf(false) } @@ -353,41 +348,14 @@ fun projectView( } } // end scrollable content column - // Fixed chat input footer — width-constrained, outside scroll - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.TopCenter, - ) { - Column( - modifier = Modifier - .widthIn(max = ThemePreferences.CONTENT_MAX_WIDTH) - .fillMaxWidth() - .padding(start = 24.dp, end = 36.dp, bottom = 24.dp), - ) { - chatInputField( - inputText = inputText, - onInputTextChange = { inputText = it }, - attachments = attachments, - onAttachmentsChange = { attachments = it }, - onSendMessage = { mode -> - if (inputText.text.isNotBlank()) { - onStartChat(currentProject.id, mode, inputText.text, attachments, currentEnabledServerIds, selectedDirective, webSearchInRag) - inputText = TextFieldValue("") - attachments = emptyList() - } - }, - onEnabledServerIdsChange = { currentEnabledServerIds = it }, - onNavigateToMcpSettings = onNavigateToMcpSettings, - selectedDirective = selectedDirective, - onToggleDirective = { selectedDirective = it }, - isProjectSession = true, - onWebSearchInRagChange = { webSearchInRag = it }, - sessionId = currentProject.id, - placeholder = stringResource("project.new.chat.placeholder", currentProject.name), - modifier = Modifier.padding(top = Spacing.large), - ) - } - } + // Fixed chat input footer — isolated composable so keystrokes only + // recompose the footer, not the entire project view. + projectChatInputFooter( + projectId = currentProject.id, + projectName = currentProject.name, + onStartChat = onStartChat, + onNavigateToMcpSettings = onNavigateToMcpSettings, + ) } // end outer Column VerticalScrollbar( @@ -1102,3 +1070,57 @@ private fun skippedFilesWarning(skippedFileNames: List) { } } } + +/** + * Isolated footer composable that owns all chat-input state. + * Keeping state here prevents keystrokes from recomposing the heavy scrollable + * content above (hero card, knowledge sources panel, session cards). + */ +@Composable +private fun projectChatInputFooter( + projectId: String, + projectName: String, + onStartChat: (projectId: String, mode: CreationMode, message: String, attachments: List, enabledServerIds: Set, directiveId: String?, useWebSearch: Boolean) -> Unit, + onNavigateToMcpSettings: (() -> Unit)? = null, +) { + var inputText by remember { mutableStateOf(TextFieldValue("")) } + var attachments by remember { mutableStateOf>(emptyList()) } + var currentEnabledServerIds by remember { mutableStateOf(emptySet()) } + var selectedDirective by remember { mutableStateOf(null) } + var webSearchInRag by remember { mutableStateOf(false) } + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = Modifier + .widthIn(max = ThemePreferences.CONTENT_MAX_WIDTH) + .fillMaxWidth() + .padding(start = 24.dp, end = 36.dp, bottom = 24.dp), + ) { + chatInputField( + inputText = inputText, + onInputTextChange = { inputText = it }, + attachments = attachments, + onAttachmentsChange = { attachments = it }, + onSendMessage = { mode -> + if (inputText.text.isNotBlank()) { + onStartChat(projectId, mode, inputText.text, attachments, currentEnabledServerIds, selectedDirective, webSearchInRag) + inputText = TextFieldValue("") + attachments = emptyList() + } + }, + onEnabledServerIdsChange = { currentEnabledServerIds = it }, + onNavigateToMcpSettings = onNavigateToMcpSettings, + selectedDirective = selectedDirective, + onToggleDirective = { selectedDirective = it }, + isProjectSession = true, + onWebSearchInRagChange = { webSearchInRag = it }, + sessionId = projectId, + placeholder = stringResource("project.new.chat.placeholder", projectName), + modifier = Modifier.padding(top = Spacing.large), + ) + } + } +}