From 269946bb0e63db671625d0879e1e964003bdfc44 Mon Sep 17 00:00:00 2001 From: Hai Phuc Nguyen <3423575+haiphucnguyen@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:06:38 -0700 Subject: [PATCH 1/3] Update --- .../io/askimo/ui/chat/ChatInputField.kt | 649 +++++++++--------- .../kotlin/io/askimo/ui/chat/ChatState.kt | 5 + .../main/kotlin/io/askimo/ui/chat/ChatView.kt | 2 + .../kotlin/io/askimo/ui/chat/ChatViewModel.kt | 42 +- .../io/askimo/ui/chat/MessageComponents.kt | 323 ++++++--- .../io/askimo/ui/session/SessionManager.kt | 13 + .../main/resources/i18n/messages.properties | 2 + .../resources/i18n/messages_de.properties | 2 + .../resources/i18n/messages_es.properties | 2 + .../resources/i18n/messages_fr.properties | 2 + .../resources/i18n/messages_ja_JP.properties | 2 + .../resources/i18n/messages_ko_KR.properties | 2 + .../resources/i18n/messages_pt_BR.properties | 2 + .../resources/i18n/messages_vi_VN.properties | 2 + .../resources/i18n/messages_zh_CN.properties | 2 + .../resources/i18n/messages_zh_TW.properties | 2 + .../core/providers/ChatClientExtensions.kt | 9 +- .../io/askimo/core/util/SystemPrompts.kt | 2 +- 18 files changed, 661 insertions(+), 404 deletions(-) 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 c58f2c699..5e9e6ff23 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 @@ -780,326 +780,16 @@ fun chatInputField( // Shown whenever directives exist; styled to match toolsIndicatorButton. if (availableDirectives.isNotEmpty()) { Spacer(modifier = Modifier.width(2.dp)) - Box { - val activeDirective = availableDirectives - .find { it.id == selectedDirective } - val activeDirectiveName = activeDirective?.name - - val chipAnchor: @Composable (@Composable () -> Unit) -> Unit = - if (activeDirective != null) { - { content -> - themedRichTooltip( - tooltipContent = { - Column( - modifier = Modifier - .widthIn(min = 400.dp, max = 500.dp) - .padding( - horizontal = Spacing.medium, - vertical = Spacing.small, - ), - verticalArrangement = Arrangement.spacedBy(Spacing.small), - ) { - Text( - text = activeDirective.name, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f, - ), - ) - Text( - text = activeDirective.content, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 10, - overflow = TextOverflow.Ellipsis, - ) - } - }, - content = content, - ) - } - } else { - { content -> - themedTooltip( - text = stringResource("chat.directive"), - content = content, - ) - } - } - - chipAnchor { - Surface( - shape = RoundedCornerShape(8.dp), - color = if (selectedDirective != null) { - MaterialTheme.colorScheme.secondaryContainer - } else { - Color.Transparent - }, - tonalElevation = if (selectedDirective != null) 2.dp else 0.dp, - modifier = Modifier - .height(28.dp) - .clip(RoundedCornerShape(8.dp)) - .clickable( - enabled = !isLoading, - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { directivePopupExpanded = true }, - ) - .pointerHoverIcon(PointerIcon.Hand), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(horizontal = 8.dp), - ) { - Icon( - Icons.Default.AutoAwesome, - contentDescription = stringResource("chat.directive"), - tint = if (selectedDirective != null) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - modifier = Modifier.size(16.dp), - ) - Text( - text = activeDirectiveName ?: stringResource("chat.directive"), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = if (selectedDirective != null) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 80.dp), - ) - } - } - } - - // Upward-opening popup anchored to the chip's top edge - if (directivePopupExpanded) { - Popup( - popupPositionProvider = object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset = IntOffset( - x = anchorBounds.left, - y = anchorBounds.top - popupContentSize.height - 4, - ) - }, - onDismissRequest = { directivePopupExpanded = false }, - properties = PopupProperties(focusable = true), - ) { - Surface( - modifier = Modifier - .widthIn(min = 350.dp, max = 420.dp), - shape = RoundedCornerShape(8.dp), - shadowElevation = 8.dp, - tonalElevation = 2.dp, - color = MaterialTheme.colorScheme.surface, - ) { - Column { - Text( - text = stringResource("chat.directive"), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding( - horizontal = Spacing.medium, - vertical = Spacing.small, - ), - ) - HorizontalDivider() - - // Directive rows — scrollable list, footer items always visible below - LazyColumn(modifier = Modifier.heightIn(max = 192.dp)) { - items(availableDirectives) { directive -> - val isSelected = selectedDirective == directive.id - themedRichTooltip( - placement = TooltipPlacement.RIGHT, - tooltipContent = { - Column( - modifier = Modifier - .widthIn(min = 350.dp, max = 420.dp) - .padding( - horizontal = Spacing.medium, - vertical = Spacing.small, - ), - verticalArrangement = Arrangement.spacedBy(Spacing.small), - ) { - Text( - text = directive.name, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f, - ), - ) - Text( - text = directive.content, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 10, - overflow = TextOverflow.Ellipsis, - ) - } - }, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onToggleDirective( - if (isSelected) null else directive.id, - ) - } - .padding( - start = Spacing.extraSmall, - end = Spacing.small, - top = 2.dp, - bottom = 2.dp, - ) - .pointerHoverIcon(PointerIcon.Hand), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox( - checked = isSelected, - onCheckedChange = { checked -> - onToggleDirective( - if (checked) directive.id else null, - ) - }, - modifier = Modifier - .size(36.dp) - .pointerHoverIcon(PointerIcon.Hand), - ) - Text( - text = directive.name, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (directive.scope == DirectiveScope.TEAM) { - Spacer(modifier = Modifier.width(4.dp)) - 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, - ), - ) - } - } - } - } // end themedRichTooltip content - } - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - - DropdownMenuItem( - text = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource("chat.directive.new"), - style = MaterialTheme.typography.bodyMedium, - ) - } - }, - onClick = { - showNewDirectiveDialog = true - directivePopupExpanded = false - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) - - DropdownMenuItem( - text = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.Edit, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource("chat.directive.manage"), - style = MaterialTheme.typography.bodyMedium, - ) - } - }, - onClick = { - showManageDirectivesDialog = true - directivePopupExpanded = false - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - val uriHandler = 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/", - ) - directivePopupExpanded = false - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) - } - } - } - } - } + directiveChip( + availableDirectives = availableDirectives, + selectedDirective = selectedDirective, + isLoading = isLoading, + onToggleDirective = onToggleDirective, + directivePopupExpanded = directivePopupExpanded, + onDirectivePopupExpandedChange = { directivePopupExpanded = it }, + onShowNewDirectiveDialog = { showNewDirectiveDialog = true }, + onShowManageDirectivesDialog = { showManageDirectivesDialog = true }, + ) } // Image mode chip — only show when user explicitly toggles to Image mode @@ -1942,3 +1632,322 @@ private fun fileAttachmentItem( } } } + +/** + * Directive selector chip + upward-opening popup. + * + * Shows the currently active directive name (or a generic label when none is selected). + * Clicking the chip opens an upward popup with a scrollable directive list, checkboxes to + * toggle selection, and footer actions to create / manage directives. + */ +@Composable +private fun directiveChip( + availableDirectives: List, + selectedDirective: String?, + isLoading: Boolean, + onToggleDirective: (String?) -> Unit, + directivePopupExpanded: Boolean, + onDirectivePopupExpandedChange: (Boolean) -> Unit, + onShowNewDirectiveDialog: () -> Unit, + onShowManageDirectivesDialog: () -> Unit, +) { + val activeDirective = availableDirectives.find { it.id == selectedDirective } + val activeDirectiveName = activeDirective?.name + + val chipAnchor: @Composable (@Composable () -> Unit) -> Unit = + if (activeDirective != null) { + { content -> + themedRichTooltip( + tooltipContent = { + Column( + modifier = Modifier + .widthIn(min = 400.dp, max = 500.dp) + .padding(horizontal = Spacing.medium, vertical = Spacing.small), + verticalArrangement = Arrangement.spacedBy(Spacing.small), + ) { + Text( + text = activeDirective.name, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Text( + text = activeDirective.content, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 10, + overflow = TextOverflow.Ellipsis, + ) + } + }, + content = content, + ) + } + } else { + { content -> + themedTooltip(text = stringResource("chat.directive"), content = content) + } + } + + Box { + chipAnchor { + Surface( + shape = RoundedCornerShape(8.dp), + color = if (selectedDirective != null) { + MaterialTheme.colorScheme.secondaryContainer + } else { + Color.Transparent + }, + tonalElevation = if (selectedDirective != null) 2.dp else 0.dp, + modifier = Modifier + .height(28.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable( + enabled = !isLoading, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { onDirectivePopupExpandedChange(true) }, + ) + .pointerHoverIcon(PointerIcon.Hand), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 8.dp), + ) { + Icon( + Icons.Default.AutoAwesome, + contentDescription = stringResource("chat.directive"), + tint = if (selectedDirective != null) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.size(16.dp), + ) + Text( + text = activeDirectiveName ?: stringResource("chat.directive"), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = if (selectedDirective != null) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 80.dp), + ) + } + } + } + + // Upward-opening popup anchored to the chip's top edge + if (directivePopupExpanded) { + Popup( + popupPositionProvider = object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset = IntOffset( + x = anchorBounds.left, + y = anchorBounds.top - popupContentSize.height - 4, + ) + }, + onDismissRequest = { onDirectivePopupExpandedChange(false) }, + properties = PopupProperties(focusable = true), + ) { + Surface( + modifier = Modifier.widthIn(min = 350.dp, max = 420.dp), + shape = RoundedCornerShape(8.dp), + shadowElevation = 8.dp, + tonalElevation = 2.dp, + color = MaterialTheme.colorScheme.surface, + ) { + Column { + Text( + text = stringResource("chat.directive"), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + horizontal = Spacing.medium, + vertical = Spacing.small, + ), + ) + HorizontalDivider() + + // Directive rows — scrollable list, footer items always visible below + LazyColumn(modifier = Modifier.heightIn(max = 192.dp)) { + items(availableDirectives) { directive -> + val isSelected = selectedDirective == directive.id + themedRichTooltip( + placement = TooltipPlacement.RIGHT, + tooltipContent = { + Column( + modifier = Modifier + .widthIn(min = 350.dp, max = 420.dp) + .padding( + horizontal = Spacing.medium, + vertical = Spacing.small, + ), + verticalArrangement = Arrangement.spacedBy(Spacing.small), + ) { + Text( + text = directive.name, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Text( + text = directive.content, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 10, + overflow = TextOverflow.Ellipsis, + ) + } + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onToggleDirective(if (isSelected) null else directive.id) + } + .padding( + start = Spacing.extraSmall, + end = Spacing.small, + top = 2.dp, + bottom = 2.dp, + ) + .pointerHoverIcon(PointerIcon.Hand), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isSelected, + onCheckedChange = { checked -> + onToggleDirective(if (checked) directive.id else null) + }, + modifier = Modifier + .size(36.dp) + .pointerHoverIcon(PointerIcon.Hand), + ) + Text( + text = directive.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (directive.scope == DirectiveScope.TEAM) { + Spacer(modifier = Modifier.width(4.dp)) + 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), + ) + } + } + } + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + DropdownMenuItem( + text = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource("chat.directive.new"), + style = MaterialTheme.typography.bodyMedium, + ) + } + }, + onClick = { + onShowNewDirectiveDialog() + onDirectivePopupExpandedChange(false) + }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) + + DropdownMenuItem( + text = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Edit, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource("chat.directive.manage"), + style = MaterialTheme.typography.bodyMedium, + ) + } + }, + onClick = { + onShowManageDirectivesDialog() + onDirectivePopupExpandedChange(false) + }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + val uriHandler = 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/") + onDirectivePopupExpandedChange(false) + }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) + } + } + } + } + } +} 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 917700c11..83f82953d 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 @@ -45,6 +45,11 @@ data class ChatState( // Tool call state — ephemeral, populated only during active streaming val activeToolCalls: List = emptyList(), + // Thinking/reasoning content — ephemeral, streamed from models that expose reasoning. + // Populated during active streaming; cleared when a new message starts. + // Not persisted to the database — visible for the current session only. + val activeThinkingContent: String = "", + // Bookmark state — IDs of messages pinned by the user in this session val bookmarkedMessageIds: Set = emptySet(), 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 f029af847..410ec7fd9 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 @@ -178,6 +178,7 @@ fun chatView( val sessionTitle = state.sessionTitle val project = state.project val activeToolCalls = state.activeToolCalls + val activeThinkingContent = state.activeThinkingContent val bookmarkedMessageIds = state.bookmarkedMessageIds val pendingScrollToMessageId = state.pendingScrollToMessageId @@ -1141,6 +1142,7 @@ fun chatView( viewportTopY = viewportBounds?.top, projectId = project?.id, activeToolCalls = activeToolCalls, + activeThinkingContent = activeThinkingContent, bookmarkedMessageIds = bookmarkedMessageIds, onToggleBookmark = { messageId -> actions.toggleBookmark(messageId) }, onForkFromMessage = { messageId -> actions.forkFromMessage(messageId) }, diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatViewModel.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatViewModel.kt index 2136a038d..cab421e66 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatViewModel.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatViewModel.kt @@ -114,6 +114,9 @@ class ChatViewModel( var activeToolCalls by mutableStateOf>(emptyList()) private set + var activeThinkingContent by mutableStateOf("") + private set + var bookmarkedMessageIds by mutableStateOf>(emptySet()) private set @@ -140,6 +143,7 @@ class ChatViewModel( sessionTitle = sessionTitle ?: "", project = project, activeToolCalls = activeToolCalls, + activeThinkingContent = activeThinkingContent, bookmarkedMessageIds = bookmarkedMessageIds, pendingScrollToMessageId = pendingScrollToMessageId, ) @@ -397,6 +401,38 @@ class ChatViewModel( } } + // Subscribe to thinking/reasoning tokens streamed by models that expose reasoning. + // Accumulate chunks into a single string; UI renders them in a collapsible section. + scope.launch { + activeThread.thinkingChunks.collect { chunks -> + if (currentSessionId.value == sessionId) { + activeThinkingContent = chunks.joinToString("") + // Ensure a placeholder bubble exists so the thinking section is visible + // even before the first response token arrives. + if (chunks.isNotEmpty() && messages.none { !it.isUser && it.id == null }) { + isThinking = false + stopThinkingTimer() + val placeholder = ChatMessageDTO( + content = "", + isUser = false, + id = null, + timestamp = null, + ) + messages = if (retryInsertPosition != null) { + messages.toMutableList().apply { add(retryInsertPosition!!, placeholder) } + } else { + val lastMsg = messages.lastOrNull() + if (lastMsg != null && !lastMsg.isUser) { + messages.dropLast(1) + placeholder + } else { + messages + placeholder + } + } + } + } + } + } + // Monitor completion in a separate job scope.launch { activeThread.isComplete.collect { isComplete -> @@ -569,8 +605,9 @@ class ChatViewModel( isThinking = true thinkingElapsedSeconds = 0 - // Clear tool chips from the previous response before retrying + // Clear tool chips and thinking content from the previous response before retrying activeToolCalls = emptyList() + activeThinkingContent = "" startThinkingTimer() @@ -649,8 +686,9 @@ class ChatViewModel( fun sendMessage(projectId: String?, mode: CreationMode, message: String, attachments: List = emptyList(), enabledServerIds: Set = emptySet()) { if (message.isBlank() || isLoading) return - // Clear tool chips from the previous response now that a new message is starting + // Clear tool chips and thinking content from the previous response now that a new message is starting activeToolCalls = emptyList() + activeThinkingContent = "" // Session ID must be set by this point (from resumeSession) val sessionId = currentSessionId.value ?: run { diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt index 1603b3e79..e38460a78 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt @@ -14,11 +14,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -29,6 +31,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallSplit import androidx.compose.material.icons.filled.AttachFile @@ -73,7 +76,9 @@ import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp @@ -133,6 +138,7 @@ fun messageList( viewportTopY: Float? = null, projectId: String? = null, activeToolCalls: List = emptyList(), + activeThinkingContent: String = "", bookmarkedMessageIds: Set = emptySet(), onToggleBookmark: ((String) -> Unit)? = null, onForkFromMessage: ((String) -> Unit)? = null, @@ -227,6 +233,7 @@ fun messageList( isStreaming = isStreamingMessage, projectId = projectId, toolCalls = if (isLastAiMsg) activeToolCalls else emptyList(), + thinkingContent = if (isLastAiMsg) activeThinkingContent else "", bookmarkedMessageIds = bookmarkedMessageIds, onToggleBookmark = onToggleBookmark, onForkFromMessage = onForkFromMessage, @@ -325,6 +332,7 @@ fun messageBubble( isStreaming: Boolean = false, projectId: String? = null, toolCalls: List = emptyList(), + thinkingContent: String = "", bookmarkedMessageIds: Set = emptySet(), onToggleBookmark: ((String) -> Unit)? = null, onForkFromMessage: ((String) -> Unit)? = null, @@ -364,6 +372,7 @@ fun messageBubble( isStreaming = isStreaming, projectId = projectId, toolCalls = toolCalls, + thinkingContent = thinkingContent, isBookmarked = message.id != null && message.id in bookmarkedMessageIds, onToggleBookmark = if (message.id != null) onToggleBookmark else null, onForkFromMessage = if (message.id != null) onForkFromMessage else null, @@ -633,6 +642,7 @@ private fun aiMessageBubble( isStreaming: Boolean = false, projectId: String? = null, toolCalls: List = emptyList(), + thinkingContent: String = "", isBookmarked: Boolean = false, onToggleBookmark: ((String) -> Unit)? = null, onForkFromMessage: ((String) -> Unit)? = null, @@ -657,6 +667,14 @@ private fun aiMessageBubble( if (hasRunningTool) toolCallsExpanded = true } + // Thinking section collapsible state — auto-expand when thinking tokens start arriving, + // keep expanded so user can review the full trace after streaming completes. + var thinkingExpanded by remember { mutableStateOf(false) } + val hasThinkingContent = thinkingContent.isNotEmpty() + LaunchedEffect(hasThinkingContent) { + if (hasThinkingContent) thinkingExpanded = true + } + Column( modifier = Modifier .fillMaxWidth() @@ -705,6 +723,16 @@ private fun aiMessageBubble( ), ) { Column { + // Thinking/reasoning collapsible section — shown when the model exposes reasoning + if (thinkingContent.isNotEmpty()) { + thinkingSection( + thinkingContent = thinkingContent, + isStreaming = isStreaming, + isExpanded = thinkingExpanded, + onToggle = { thinkingExpanded = !thinkingExpanded }, + ) + } + // Tool call collapsible section — shown only during streaming if (toolCalls.isNotEmpty()) { toolCallsSection( @@ -1118,6 +1146,119 @@ private fun aiMessageBubble( } } +/** + * Collapsible section that displays AI thinking/reasoning tokens above the response text. + * + * Auto-expands as soon as the first thinking token arrives during streaming; stays expanded + * so the user can review the full trace after the response completes. Can be collapsed + * manually by clicking the header. + * + * Displays a "💭 Thinking…" header while streaming and "💭 Thought" when complete. + * The body uses a muted italic style visually distinct from the main response. + */ +@Composable +private fun thinkingSection( + thinkingContent: String, + isStreaming: Boolean, + isExpanded: Boolean, + onToggle: () -> Unit, +) { + val headerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + val headerText = if (isStreaming) { + stringResource("message.thinking.section.streaming") + } else { + stringResource("message.thinking.section.done") + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = Spacing.medium, end = Spacing.medium, top = Spacing.small), + ) { + // Header row — clickable to toggle expand/collapse + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .pointerHoverIcon(PointerIcon.Hand) + .padding(vertical = Spacing.extraSmall), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.extraSmall), + ) { + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = headerColor, + ) + Text( + text = headerText, + style = MaterialTheme.typography.labelSmall, + color = headerColor, + fontStyle = FontStyle.Italic, + ) + } + + // Expanded body — blockquote style: left accent bar + scrollable muted text + if (isExpanded && thinkingContent.isNotEmpty()) { + val scrollState = rememberScrollState() + val density = LocalDensity.current + var viewportHeightPx by remember { mutableStateOf(0) } + + LaunchedEffect(thinkingContent) { + if (isStreaming) scrollState.animateScrollTo(scrollState.maxValue) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(top = Spacing.extraSmall) + .onSizeChanged { viewportHeightPx = it.height }, + ) { + // Left accent bar — blockquote-style vertical line, stretches to match content height + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background( + color = MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(2.dp), + ), + ) + + // Scrollable content next to the bar + Box(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .verticalScroll(scrollState) + .padding(start = Spacing.small, end = Spacing.medium, top = 2.dp, bottom = 2.dp), + ) { + Text( + text = thinkingContent, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + fontStyle = FontStyle.Italic, + ) + } + // Only render once we know the viewport height (after the first layout pass). + if (viewportHeightPx > 0) { + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .height(with(density) { viewportHeightPx.toDp() }), + adapter = rememberScrollbarAdapter(scrollState), + style = AppComponents.scrollbarStyle(), + ) + } + } + } + } + } +} + /** * Collapsible section that displays AI tool calls above the response text. * Shows "▶ Running tool…" when any tool is active, "▶ Used N tool(s)" when all are done. @@ -1184,6 +1325,11 @@ private fun toolCallsSection( /** * Single row showing a tool name, its running/done/failed status icon, and an optional * expandable section with the raw arguments and result. + * + * Uses a blockquote-style left accent bar whose colour reflects the call's status: + * • running → onSurfaceVariant (neutral) + * • done → primary (success) + * • failed → error (destructive) */ @Composable private fun toolCallRow(toolCall: ToolCallInfo) { @@ -1193,104 +1339,121 @@ private fun toolCallRow(toolCall: ToolCallInfo) { var detailsExpanded by remember { mutableStateOf(false) } - val statusColor = when { + val barColor = when { + hasFailed -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) + isDone -> MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + else -> MaterialTheme.colorScheme.outlineVariant + } + val statusIconColor = when { hasFailed -> MaterialTheme.colorScheme.error.copy(alpha = 0.8f) isDone -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) } - Column( + Row( modifier = Modifier - .background( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - shape = RoundedCornerShape(6.dp), - ) - .padding(horizontal = Spacing.small, vertical = 3.dp), + .fillMaxWidth() + .height(IntrinsicSize.Min), ) { - // Header row: icon + name + status indicator (+ expand chevron if details exist) - Row( - modifier = if (hasDetails && isDone) { - Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { detailsExpanded = !detailsExpanded }, - ) - .pointerHoverIcon(PointerIcon.Hand) - } else { - Modifier.fillMaxWidth() - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.extraSmall), - ) { - Icon( - imageVector = Icons.Default.Build, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = statusColor, - ) - Text( - text = toolCall.toolName, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - // Status indicator - when { - !isDone -> Text( - text = "⏳", - style = MaterialTheme.typography.labelSmall, - color = statusColor, - ) - - hasFailed -> Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource("tool.call.status.failed"), - modifier = Modifier.size(12.dp), - tint = statusColor, - ) + // Left accent bar — colour reflects tool status + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background(color = barColor, shape = RoundedCornerShape(2.dp)), + ) - else -> Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource("tool.call.status.done"), - modifier = Modifier.size(12.dp), - tint = statusColor, - ) - } - // Expand chevron (only when done and has details) - if (hasDetails && isDone) { + // Content column — header + optional expandable details + Column( + modifier = Modifier + .weight(1f) + .padding(start = Spacing.small), + ) { + // Header row: icon + name + status indicator (+ expand chevron if details exist) + Row( + modifier = if (hasDetails && isDone) { + Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { detailsExpanded = !detailsExpanded }, + ) + .pointerHoverIcon(PointerIcon.Hand) + } else { + Modifier.fillMaxWidth() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.extraSmall), + ) { Icon( - imageVector = if (detailsExpanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight, + imageVector = Icons.Default.Build, contentDescription = null, modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + tint = statusIconColor, ) - } - } + Text( + text = toolCall.toolName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + // Status indicator + when { + !isDone -> Text( + text = "⏳", + style = MaterialTheme.typography.labelSmall, + color = statusIconColor, + ) - // Expandable details: arguments + result - if (detailsExpanded && hasDetails) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = Spacing.extraSmall), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - if (!toolCall.arguments.isNullOrBlank()) { - toolCallDetailSection( - label = stringResource("tool.call.detail.arguments"), - content = toolCall.arguments!!, + hasFailed -> Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource("tool.call.status.failed"), + modifier = Modifier.size(12.dp), + tint = statusIconColor, + ) + + else -> Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource("tool.call.status.done"), + modifier = Modifier.size(12.dp), + tint = statusIconColor, ) } - if (!toolCall.result.isNullOrBlank()) { - toolCallDetailSection( - label = stringResource("tool.call.detail.result"), - content = toolCall.result!!, - labelColor = if (hasFailed) MaterialTheme.colorScheme.error.copy(alpha = 0.8f) else null, + // Expand chevron (only when done and has details) + if (hasDetails && isDone) { + Icon( + imageVector = if (detailsExpanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) } } + + // Expandable details: arguments + result + if (detailsExpanded && hasDetails) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = Spacing.extraSmall), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (!toolCall.arguments.isNullOrBlank()) { + toolCallDetailSection( + label = stringResource("tool.call.detail.arguments"), + content = toolCall.arguments!!, + ) + } + if (!toolCall.result.isNullOrBlank()) { + toolCallDetailSection( + label = stringResource("tool.call.detail.result"), + content = toolCall.result!!, + labelColor = if (hasFailed) MaterialTheme.colorScheme.error.copy(alpha = 0.8f) else null, + ) + } + } + } } } } 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 f3114c338..a5df6a880 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 @@ -125,11 +125,13 @@ class SessionManager( private val _hasFailed: MutableStateFlow, private val _savedMessage: MutableStateFlow = MutableStateFlow(null), private val _toolCalls: MutableStateFlow> = MutableStateFlow(emptyList()), + private val _thinkingChunks: MutableStateFlow> = MutableStateFlow(emptyList()), ) { val chunks: StateFlow> = _chunks.asStateFlow() val isComplete: StateFlow = _isComplete.asStateFlow() val savedMessage: StateFlow = _savedMessage.asStateFlow() val toolCalls: StateFlow> = _toolCalls.asStateFlow() + val thinkingChunks: StateFlow> = _thinkingChunks.asStateFlow() private val mutex = Mutex() @@ -139,6 +141,12 @@ class SessionManager( } } + suspend fun appendThinkingChunk(chunk: String) { + mutex.withLock { + _thinkingChunks.value += chunk + } + } + suspend fun markComplete() { mutex.withLock { _isComplete.value = true @@ -308,6 +316,11 @@ class SessionManager( thread.markToolDone(toolName, arguments, result, hasFailed) } }, + onThinkingToken = { token -> + streamingScope.launch { + thread.appendThinkingChunk(token) + } + }, ) val savedMessage = chatSessionService.saveAiResponse( diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index 451f628f0..8d11add05 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -814,6 +814,8 @@ log.level.trace.description=Show all log messages (most verbose) # Message Components message.loading.previous=Loading previous messages... message.thinking=Thinking... ({0}s) +message.thinking.section.streaming=💭 Thinking... +message.thinking.section.done=💭 Thought message.copy=Copy message message.copy.description=Copy message message.edit=Edit message diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index 359b0a8ab..2f789b737 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -790,6 +790,8 @@ log.level.trace.description=Alle Log-Nachrichten anzeigen (sehr detailliert) # Message Components message.loading.previous=Vorherige Nachrichten werden geladen... message.thinking=Denke nach… ({0}s) +message.thinking.section.streaming=💭 Denkt... +message.thinking.section.done=💭 Gedanke message.copy=Nachricht kopieren message.copy.description=Nachricht kopieren message.edit=Nachricht bearbeiten diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index b17778704..ea0d9bda7 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -789,6 +789,8 @@ log.level.trace.description=Mostrar todos los registros (más detallado) # Message Components message.loading.previous=Cargando mensajes anteriores... message.thinking=Pensando… ({0}s) +message.thinking.section.streaming=💭 Pensando... +message.thinking.section.done=💭 Pensamiento message.copy=Copiar mensaje message.copy.description=Copiar mensaje message.edit=Editar mensaje diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index 337fa90b3..61a659a07 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -789,6 +789,8 @@ log.level.trace.description=Afficher tous les logs (le plus détaillé) # Message Components message.loading.previous=Chargement des messages précédents... message.thinking=Réflexion… ({0}s) +message.thinking.section.streaming=💭 Réflexion... +message.thinking.section.done=💭 Pensée message.copy=Copier le message message.copy.description=Copier le message message.edit=Modifier le message 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 faef32bb8..2dca450f5 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -788,6 +788,8 @@ log.level.trace.description=すべてのログを表示(最も詳細) # Message Components message.loading.previous=以前のメッセージを読み込み中... message.thinking=考え中… ({0}s) +message.thinking.section.streaming=💭 考え中... +message.thinking.section.done=💭 考え message.copy=メッセージをコピー message.copy.description=メッセージをコピー message.edit=メッセージ編集 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 d8782ba67..afce9c91b 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -789,6 +789,8 @@ log.level.trace.description=모든 로그 표시 (가장 상세) # Message Components message.loading.previous=이전 메시지 불러오는 중... message.thinking=생각 중… ({0}s) +message.thinking.section.streaming=💭 생각 중... +message.thinking.section.done=💭 생각 message.copy=메시지 복사 message.copy.description=메시지 복사 message.edit=메시지 편집 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 de6e6996c..08d9fad0c 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -791,6 +791,8 @@ log.level.trace.description=Mostrar todas as mensagens (mais detalhado) # Message Components message.loading.previous=Carregando mensagens anteriores... message.thinking=Pensando… ({0}s) +message.thinking.section.streaming=💭 Pensando... +message.thinking.section.done=💭 Pensamento message.copy=Copiar mensagem message.copy.description=Copiar mensagem message.edit=Editar mensagem 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 2e384da6f..62cdeb6fa 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -787,6 +787,8 @@ log.level.trace.description=Chi tiết nhất # Message Components message.loading.previous=Đang tải các tin nhắn trước... message.thinking=Đang suy nghĩ… ({0}s) +message.thinking.section.streaming=💭 Đang suy nghĩ... +message.thinking.section.done=💭 Suy nghĩ message.copy=Sao chép tin nhắn message.copy.description=Sao chép tin nhắn message.edit=Chỉnh sửa tin nhắ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 6dca0a0c1..972758c0e 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -788,6 +788,8 @@ log.level.trace.description=显示所有日志(最详细) # Message Components message.loading.previous=正在加载更早的消息... message.thinking=思考中… ({0}s) +message.thinking.section.streaming=💭 思考中... +message.thinking.section.done=💭 已思考 message.copy=复制消息 message.copy.description=复制消息 message.edit=编辑消息 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 ac6364bbc..2fa72afca 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -788,6 +788,8 @@ log.level.trace.description=顯示所有日誌(最詳細) # Message Components message.loading.previous=正在載入先前訊息... message.thinking=思考中… ({0}s) +message.thinking.section.streaming=💭 思考中... +message.thinking.section.done=💭 已思考 message.copy=複製訊息 message.copy.description=複製訊息 message.edit=編輯訊息 diff --git a/shared/src/main/kotlin/io/askimo/core/providers/ChatClientExtensions.kt b/shared/src/main/kotlin/io/askimo/core/providers/ChatClientExtensions.kt index b2b749c97..fb86c27f4 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/ChatClientExtensions.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/ChatClientExtensions.kt @@ -71,6 +71,7 @@ fun ChatClient.sendStreamingMessageWithCallback( onTokenUsage: ((inputTokens: Int, outputTokens: Int, totalTokens: Int, durationMs: Long) -> Unit)? = null, onToolStarted: ((toolName: String, arguments: String?) -> Unit)? = null, onToolFinished: ((toolName: String, arguments: String?, result: String?, hasFailed: Boolean) -> Unit)? = null, + onThinkingToken: ((String) -> Unit)? = null, ): String { val log = logger() @@ -107,7 +108,13 @@ fun ChatClient.sendStreamingMessageWithCallback( .onPartialResponse { chunk -> sb.append(chunk) onToken(chunk) - }.onCompleteResponse { response -> + }.onPartialThinking { thinking -> + val text = thinking.text() + if (!text.isNullOrEmpty()) { + onThinkingToken?.invoke(text) + } + } + .onCompleteResponse { response -> val aiMessage = response.aiMessage() val tokenUsage = response.tokenUsage() diff --git a/shared/src/main/kotlin/io/askimo/core/util/SystemPrompts.kt b/shared/src/main/kotlin/io/askimo/core/util/SystemPrompts.kt index 8b45464ac..ffc4082fa 100644 --- a/shared/src/main/kotlin/io/askimo/core/util/SystemPrompts.kt +++ b/shared/src/main/kotlin/io/askimo/core/util/SystemPrompts.kt @@ -8,7 +8,7 @@ object SystemPrompts { val markdownDefaults = listOf( "By default, always respond in GitHub-Flavored Markdown.", - "Never return HTML unless explicitly asked.", + "If you are uncertain or lack reliable information about something, acknowledge it openly and qualify your answer — do not present guesses as facts.", ) fun systemMessage(vararg extras: String) = (markdownDefaults + extras).joinToString("\n\n") From bf53bf114086841af4ef9dbd1d39add123be2e15 Mon Sep 17 00:00:00 2001 From: Hai Phuc Nguyen <3423575+haiphucnguyen@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:45:48 -0700 Subject: [PATCH 2/3] Update --- .../io/askimo/ui/chat/ChatInputField.kt | 274 +++++++++--------- 1 file changed, 129 insertions(+), 145 deletions(-) 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 5e9e6ff23..aba128ff2 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,8 +23,6 @@ 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.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer @@ -42,9 +40,9 @@ import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Badge -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator @@ -58,7 +56,6 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -1161,20 +1158,23 @@ private fun toolsIndicatorButton( ) { Surface( modifier = Modifier - .widthIn(min = 300.dp, max = 420.dp) - .heightIn(max = 400.dp), + .widthIn(min = 300.dp, max = 420.dp), shape = MaterialTheme.shapes.medium, shadowElevation = 8.dp, color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp, ) { - Column( - modifier = Modifier.padding(Spacing.large), - verticalArrangement = Arrangement.spacedBy(Spacing.medium), - ) { - // Header + Column { + // Header: title on left, settings icon button on right Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding( + start = Spacing.medium, + end = Spacing.extraSmall, + top = Spacing.small, + bottom = Spacing.small, + ), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -1183,6 +1183,26 @@ private fun toolsIndicatorButton( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) + if (onNavigateToMcpSettings != null) { + themedTooltip(text = stringResource("chat.tools.popup.manage")) { + IconButton( + onClick = { + showToolsPopup = false + onNavigateToMcpSettings() + }, + modifier = Modifier + .size(32.dp) + .pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + Icons.Outlined.Settings, + contentDescription = stringResource("chat.tools.popup.manage"), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } HorizontalDivider() @@ -1191,8 +1211,11 @@ private fun toolsIndicatorButton( when { isLoadingServers -> { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.medium, vertical = Spacing.medium), horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, ) { CircularProgressIndicator( modifier = Modifier.size(24.dp), @@ -1208,17 +1231,22 @@ private fun toolsIndicatorButton( text = stringResource("chat.tools.popup.no.servers.global"), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + horizontal = Spacing.medium, + vertical = Spacing.medium, + ), ) } else -> { - // List MCP servers + // Server list — scrollable, capped at max height; popup expands freely Column( modifier = Modifier .fillMaxWidth() .heightIn(max = 300.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), + .verticalScroll(rememberScrollState()) + .padding(Spacing.small), + verticalArrangement = Arrangement.spacedBy(Spacing.small), ) { mcpServers.forEach { server -> mcpServerItem( @@ -1239,37 +1267,6 @@ private fun toolsIndicatorButton( } } } - - // Footer link to MCP settings - if (onNavigateToMcpSettings != null) { - HorizontalDivider() - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton( - onClick = { - showToolsPopup = false - onNavigateToMcpSettings() - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) { - Icon( - Icons.Outlined.Settings, - contentDescription = null, - modifier = Modifier.size(14.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource("chat.tools.popup.manage"), - style = MaterialTheme.typography.labelMedium, - ) - } - } - } } } } @@ -1653,6 +1650,7 @@ private fun directiveChip( ) { val activeDirective = availableDirectives.find { it.id == selectedDirective } val activeDirectiveName = activeDirective?.name + val uriHandler = LocalUriHandler.current val chipAnchor: @Composable (@Composable () -> Unit) -> Unit = if (activeDirective != null) { @@ -1699,9 +1697,9 @@ private fun directiveChip( color = if (selectedDirective != null) { MaterialTheme.colorScheme.secondaryContainer } else { - Color.Transparent + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) }, - tonalElevation = if (selectedDirective != null) 2.dp else 0.dp, + tonalElevation = 2.dp, modifier = Modifier .height(28.dp) .clip(RoundedCornerShape(8.dp)) @@ -1724,7 +1722,7 @@ private fun directiveChip( tint = if (selectedDirective != null) { MaterialTheme.colorScheme.onSecondaryContainer } else { - MaterialTheme.colorScheme.onSurface + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.6f) }, modifier = Modifier.size(16.dp), ) @@ -1735,7 +1733,7 @@ private fun directiveChip( color = if (selectedDirective != null) { MaterialTheme.colorScheme.onSecondaryContainer } else { - MaterialTheme.colorScheme.onSurface + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.6f) }, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -1764,26 +1762,92 @@ private fun directiveChip( ) { Surface( modifier = Modifier.widthIn(min = 350.dp, max = 420.dp), - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.medium, shadowElevation = 8.dp, - tonalElevation = 2.dp, color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, ) { Column { - Text( - text = stringResource("chat.directive"), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding( - horizontal = Spacing.medium, - vertical = Spacing.small, - ), - ) + // Header: title on left, action icon buttons on right + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = Spacing.medium, + end = Spacing.extraSmall, + top = Spacing.small, + bottom = Spacing.small, + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource("chat.directive"), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) { + themedTooltip(text = stringResource("chat.directive.new")) { + IconButton( + onClick = { + onShowNewDirectiveDialog() + onDirectivePopupExpandedChange(false) + }, + modifier = Modifier.size(32.dp).pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource("chat.directive.new"), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + themedTooltip(text = stringResource("chat.directive.manage")) { + IconButton( + onClick = { + onShowManageDirectivesDialog() + onDirectivePopupExpandedChange(false) + }, + modifier = Modifier.size(32.dp).pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + Icons.Outlined.Settings, + contentDescription = stringResource("chat.directive.manage"), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + themedTooltip(text = stringResource("chat.directive.learn.more")) { + IconButton( + onClick = { + uriHandler.openUri("https://$DOMAIN/docs/desktop/directives/") + onDirectivePopupExpandedChange(false) + }, + modifier = Modifier.size(32.dp).pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + Icons.Outlined.Info, + contentDescription = stringResource("chat.directive.learn.more"), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + HorizontalDivider() - // Directive rows — scrollable list, footer items always visible below - LazyColumn(modifier = Modifier.heightIn(max = 192.dp)) { - items(availableDirectives) { directive -> + // Directive rows — scrollable list capped at max height; popup expands freely + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 280.dp) + .verticalScroll(rememberScrollState()), + ) { + availableDirectives.forEach { directive -> val isSelected = selectedDirective == directive.id themedRichTooltip( placement = TooltipPlacement.RIGHT, @@ -1865,86 +1929,6 @@ private fun directiveChip( } } } - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - - DropdownMenuItem( - text = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource("chat.directive.new"), - style = MaterialTheme.typography.bodyMedium, - ) - } - }, - onClick = { - onShowNewDirectiveDialog() - onDirectivePopupExpandedChange(false) - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) - - DropdownMenuItem( - text = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.Edit, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource("chat.directive.manage"), - style = MaterialTheme.typography.bodyMedium, - ) - } - }, - onClick = { - onShowManageDirectivesDialog() - onDirectivePopupExpandedChange(false) - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - val uriHandler = 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/") - onDirectivePopupExpandedChange(false) - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) } } } From c084cc59f8af83b266c0a87f83143a443c61bc0b Mon Sep 17 00:00:00 2001 From: Hai Phuc Nguyen <3423575+haiphucnguyen@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:53:08 -0700 Subject: [PATCH 3/3] Update --- .../io/askimo/ui/chat/ChatInputField.kt | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) 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 aba128ff2..c19df1aa4 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 @@ -47,16 +47,19 @@ import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalRippleConfiguration import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -758,7 +761,7 @@ fun chatInputField( } } - Spacer(modifier = Modifier.width(2.dp)) + Spacer(modifier = Modifier.width(6.dp)) } toolsIndicatorButton( @@ -776,7 +779,7 @@ fun chatInputField( // ── Directive chip — inline in controls row ───────────────── // Shown whenever directives exist; styled to match toolsIndicatorButton. if (availableDirectives.isNotEmpty()) { - Spacer(modifier = Modifier.width(2.dp)) + Spacer(modifier = Modifier.width(6.dp)) directiveChip( availableDirectives = availableDirectives, selectedDirective = selectedDirective, @@ -1326,18 +1329,21 @@ private fun mcpServerItem( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Checkbox( - checked = isEnabled, - onCheckedChange = { onToggle() }, - modifier = Modifier - .size(36.dp) - .pointerHoverIcon(PointerIcon.Hand) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { /* Handled by onCheckedChange */ }, - ), - ) + @OptIn(ExperimentalMaterial3Api::class) + CompositionLocalProvider(LocalRippleConfiguration provides null) { + Checkbox( + checked = isEnabled, + onCheckedChange = { onToggle() }, + modifier = Modifier + .size(36.dp) + .pointerHoverIcon(PointerIcon.Hand) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { /* Handled by onCheckedChange */ }, + ), + ) + } // Server info Column(modifier = Modifier.weight(1f)) { @@ -1895,15 +1901,18 @@ private fun directiveChip( .pointerHoverIcon(PointerIcon.Hand), verticalAlignment = Alignment.CenterVertically, ) { - Checkbox( - checked = isSelected, - onCheckedChange = { checked -> - onToggleDirective(if (checked) directive.id else null) - }, - modifier = Modifier - .size(36.dp) - .pointerHoverIcon(PointerIcon.Hand), - ) + @OptIn(ExperimentalMaterial3Api::class) + CompositionLocalProvider(LocalRippleConfiguration provides null) { + Checkbox( + checked = isSelected, + onCheckedChange = { checked -> + onToggleDirective(if (checked) directive.id else null) + }, + modifier = Modifier + .size(36.dp) + .pointerHoverIcon(PointerIcon.Hand), + ) + } Text( text = directive.name, style = MaterialTheme.typography.bodyMedium,