diff --git a/cli/src/main/resources/META-INF/native-image/reflect-config.json b/cli/src/main/resources/META-INF/native-image/reflect-config.json index bdd90d64..b2d746df 100644 --- a/cli/src/main/resources/META-INF/native-image/reflect-config.json +++ b/cli/src/main/resources/META-INF/native-image/reflect-config.json @@ -4974,7 +4974,7 @@ ] }, { - "name": "io.askimo.core.providers.ChatClient$MockitoMock$LyMoAAaM", + "name": "io.askimo.core.providers.ChatClient$MockitoMock$Xm2yYCXe", "queryAllDeclaredConstructors": true, "methods": [{ "name": "", "parameterTypes": [] }] }, @@ -7373,7 +7373,7 @@ ] }, { - "name": "org.jline.reader.ParsedLine$MockitoMock$dZdAwrXL", + "name": "org.jline.reader.ParsedLine$MockitoMock$BQiCsOjI", "queryAllDeclaredConstructors": true, "methods": [{ "name": "", "parameterTypes": [] }] }, diff --git a/cli/src/test/kotlin/io/askimo/cli/commands/ListRecipesCommandHandlerTest.kt b/cli/src/test/kotlin/io/askimo/cli/commands/ListRecipesCommandHandlerTest.kt index aa5e47d4..2e451913 100644 --- a/cli/src/test/kotlin/io/askimo/cli/commands/ListRecipesCommandHandlerTest.kt +++ b/cli/src/test/kotlin/io/askimo/cli/commands/ListRecipesCommandHandlerTest.kt @@ -267,7 +267,7 @@ class ListRecipesCommandHandlerTest : CommandHandlerTestBase() { val recipeLine = lines.find { it.contains("my-recipe") } assertTrue(recipeLine != null) - assertTrue(recipeLine!!.contains("my-recipe")) + assertTrue(recipeLine.contains("my-recipe")) assertTrue(!recipeLine.endsWith(".yml")) } diff --git a/cli/src/test/kotlin/io/askimo/core/chat/repository/SessionMemoryRepositoryIT.kt b/cli/src/test/kotlin/io/askimo/core/chat/repository/SessionMemoryRepositoryIT.kt index fba6e7fb..9d5cfeb1 100644 --- a/cli/src/test/kotlin/io/askimo/core/chat/repository/SessionMemoryRepositoryIT.kt +++ b/cli/src/test/kotlin/io/askimo/core/chat/repository/SessionMemoryRepositoryIT.kt @@ -76,7 +76,7 @@ class SessionMemoryRepositoryIT { val retrieved = memoryRepository.getBySessionId(session.id) assertNotNull(retrieved) - assertEquals(session.id, retrieved!!.sessionId) + assertEquals(session.id, retrieved.sessionId) assertEquals(memory.memorySummary, retrieved.memorySummary) assertEquals(memory.memoryMessages, retrieved.memoryMessages) } @@ -131,7 +131,7 @@ class SessionMemoryRepositoryIT { val retrieved = memoryRepository.getBySessionId(session.id) assertNotNull(retrieved) - assertEquals(memory2.memorySummary, retrieved!!.memorySummary) + assertEquals(memory2.memorySummary, retrieved.memorySummary) assertEquals(memory2.memoryMessages, retrieved.memoryMessages) } 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 b9cdd552..09c55653 100644 --- a/cli/src/test/kotlin/io/askimo/core/providers/ChatRequestTransformersTest.kt +++ b/cli/src/test/kotlin/io/askimo/core/providers/ChatRequestTransformersTest.kt @@ -167,11 +167,11 @@ class ChatRequestTransformersTest { } assertTrue( - resultTexts.any { it?.contains("Recent user question") == true }, + resultTexts.any { it.contains("Recent user question") }, "Should keep most recent user message", ) assertTrue( - resultTexts.any { it?.contains("Recent AI answer") == true }, + resultTexts.any { it.contains("Recent AI answer") }, "Should keep most recent AI message", ) } diff --git a/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerLinuxIntegrationTest.kt b/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerLinuxIntegrationTest.kt index 326c7c8f..f6fb8887 100644 --- a/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerLinuxIntegrationTest.kt +++ b/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerLinuxIntegrationTest.kt @@ -80,10 +80,10 @@ class KeychainManagerLinuxIntegrationTest { val retrievedKey = KeychainManager.retrieveSecretKey(provider) assertNotNull(retrievedKey, "Retrieved API key should not be null") - println("Retrieved length: ${retrievedKey?.length ?: 0}") - println("Retrieved prefix: ${retrievedKey?.take(20) ?: "null"}...") + println("Retrieved length: ${retrievedKey.length}") + println("Retrieved prefix: ${retrievedKey.take(20)}...") - assertEquals(LONG_API_KEY.length, retrievedKey?.length, "Retrieved API key length should match original") + assertEquals(LONG_API_KEY.length, retrievedKey.length, "Retrieved API key length should match original") assertEquals(LONG_API_KEY, retrievedKey, "Retrieved API key should match stored key exactly") } @@ -191,7 +191,7 @@ class KeychainManagerLinuxIntegrationTest { val retrievedKey = KeychainManager.retrieveSecretKey(provider) assertNotNull(retrievedKey, "Very long API key should be retrievable") - assertEquals(veryLongKey.length, retrievedKey?.length, "Very long API key length should match") + assertEquals(veryLongKey.length, retrievedKey.length, "Very long API key length should match") assertEquals(veryLongKey, retrievedKey, "Very long API key should match exactly") } diff --git a/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerMacOSIntegrationTest.kt b/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerMacOSIntegrationTest.kt index 0ed92fd2..56b3c8a5 100644 --- a/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerMacOSIntegrationTest.kt +++ b/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerMacOSIntegrationTest.kt @@ -80,8 +80,8 @@ class KeychainManagerMacOSIntegrationTest { val retrievedKey = KeychainManager.retrieveSecretKey(provider) assertNotNull(retrievedKey, "Retrieved API key should not be null") - println("Retrieved length: ${retrievedKey?.length ?: 0}") - println("Retrieved prefix: ${retrievedKey?.take(20) ?: "null"}...") + println("Retrieved length: ${retrievedKey.length}") + println("Retrieved prefix: ${retrievedKey.take(20)}...") assertEquals(LONG_API_KEY.length, retrievedKey?.length, "Retrieved API key length should match original") assertEquals(LONG_API_KEY, retrievedKey, "Retrieved API key should match stored key exactly") @@ -191,7 +191,7 @@ class KeychainManagerMacOSIntegrationTest { val retrievedKey = KeychainManager.retrieveSecretKey(provider) assertNotNull(retrievedKey, "Very long API key should be retrievable") - assertEquals(veryLongKey.length, retrievedKey?.length, "Very long API key length should match") + assertEquals(veryLongKey.length, retrievedKey.length, "Very long API key length should match") assertEquals(veryLongKey, retrievedKey, "Very long API key should match exactly") } diff --git a/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerWindowsIntegrationTest.kt b/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerWindowsIntegrationTest.kt index 9ca67b16..8c1042c9 100644 --- a/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerWindowsIntegrationTest.kt +++ b/cli/src/test/kotlin/io/askimo/core/security/KeychainManagerWindowsIntegrationTest.kt @@ -85,10 +85,10 @@ class KeychainManagerWindowsIntegrationTest { val retrievedKey = KeychainManager.retrieveSecretKey(provider) assertNotNull(retrievedKey, "Retrieved API key should not be null") - println("Retrieved length: ${retrievedKey?.length ?: 0}") - println("Retrieved prefix: ${retrievedKey?.take(20) ?: "null"}...") + println("Retrieved length: ${retrievedKey.length}") + println("Retrieved prefix: ${retrievedKey.take(20)}...") - assertEquals(LONG_API_KEY.length, retrievedKey?.length, "Retrieved API key length should match original") + assertEquals(LONG_API_KEY.length, retrievedKey.length, "Retrieved API key length should match original") assertEquals(LONG_API_KEY, retrievedKey, "Retrieved API key should match stored key exactly") } @@ -181,7 +181,7 @@ class KeychainManagerWindowsIntegrationTest { val retrievedKey = KeychainManager.retrieveSecretKey(provider) assertNotNull(retrievedKey, "Very long API key should be retrievable") - assertEquals(veryLongKey.length, retrievedKey?.length, "Very long API key length should match") + assertEquals(veryLongKey.length, retrievedKey.length, "Very long API key length should match") assertEquals(veryLongKey, retrievedKey, "Very long API key should match exactly") } diff --git a/cli/src/test/kotlin/io/askimo/core/security/SecureKeyManagerMacOSIntegrationTest.kt b/cli/src/test/kotlin/io/askimo/core/security/SecureKeyManagerMacOSIntegrationTest.kt index 168a9c4e..322ec0bd 100644 --- a/cli/src/test/kotlin/io/askimo/core/security/SecureKeyManagerMacOSIntegrationTest.kt +++ b/cli/src/test/kotlin/io/askimo/core/security/SecureKeyManagerMacOSIntegrationTest.kt @@ -69,7 +69,7 @@ class SecureKeyManagerMacOSIntegrationTest { println("Retrieved key is null: ${retrievedKey == null}") assertNotNull(retrievedKey, "Retrieved API key should not be null") - val retrieved = retrievedKey!! + val retrieved = retrievedKey println("Retrieved length: ${retrieved.length}") println("Retrieved prefix: ${retrieved.take(20)}...") println("Retrieved suffix: ...${retrieved.takeLast(20)}") diff --git a/cli/src/test/kotlin/io/askimo/core/security/SecureKeyManagerWindowsIntegrationTest.kt b/cli/src/test/kotlin/io/askimo/core/security/SecureKeyManagerWindowsIntegrationTest.kt index 278d6c98..01ccbec3 100644 --- a/cli/src/test/kotlin/io/askimo/core/security/SecureKeyManagerWindowsIntegrationTest.kt +++ b/cli/src/test/kotlin/io/askimo/core/security/SecureKeyManagerWindowsIntegrationTest.kt @@ -74,7 +74,7 @@ class SecureKeyManagerWindowsIntegrationTest { println("Retrieved key is null: ${retrievedKey == null}") assertNotNull(retrievedKey, "Retrieved API key should not be null") - val retrieved = retrievedKey!! + val retrieved = retrievedKey println("Retrieved length: ${retrieved.length}") println("Retrieved prefix: ${retrieved.take(20)}...") println("Retrieved suffix: ...${retrieved.takeLast(20)}") 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 c34ca429..7254655c 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 @@ -367,44 +367,44 @@ fun chatInputField( Column { // Main input row with text field and send button + // Resize handle sits above the row so it doesn't affect button alignment. + Box( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .pointerHoverIcon( + PointerIcon(Cursor.getPredefinedCursor(Cursor.N_RESIZE_CURSOR)), + ) + .pointerInput(Unit) { + detectVerticalDragGestures { change, dragAmount -> + change.consume() + val newHeight = textFieldHeight - dragAmount.toDp() + textFieldHeight = newHeight.coerceIn(defaultTextFieldHeight, maxTextFieldHeight) + manuallyResized = true + } + }, + contentAlignment = Alignment.Center, + ) { + // Visual grip indicator + Box( + modifier = Modifier + .width(40.dp) + .height(4.dp) + .background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(2.dp), + ), + ) + } + Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, + verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1f), ) { - // Resize handle - Box( - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) - .pointerHoverIcon( - PointerIcon(Cursor.getPredefinedCursor(Cursor.N_RESIZE_CURSOR)), - ) - .pointerInput(Unit) { - detectVerticalDragGestures { change, dragAmount -> - change.consume() - val newHeight = textFieldHeight - dragAmount.toDp() - textFieldHeight = newHeight.coerceIn(defaultTextFieldHeight, maxTextFieldHeight) - manuallyResized = true - } - }, - contentAlignment = Alignment.Center, - ) { - // Visual indicator - Box( - modifier = Modifier - .width(40.dp) - .height(4.dp) - .background( - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - RoundedCornerShape(2.dp), - ), - ) - } - // Text field OutlinedTextField( value = inputText, @@ -506,11 +506,7 @@ fun chatInputField( Spacer(modifier = Modifier.width(8.dp)) - // Send/Stop button - Box( - modifier = Modifier.height(textFieldHeight), - contentAlignment = Alignment.Center, - ) { + Box(contentAlignment = Alignment.Center) { if (isLoading || isThinking) { IconButton( onClick = onStopResponse, diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MarkdownText.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MarkdownText.kt index f48bed34..6d6edb9b 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MarkdownText.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MarkdownText.kt @@ -115,6 +115,7 @@ import java.net.URI import java.util.Base64 import javax.imageio.ImageIO import javax.swing.SwingUtilities +import kotlin.time.Duration.Companion.milliseconds import org.commonmark.node.Text as MarkdownText private val log = currentFileLogger() @@ -804,7 +805,7 @@ private fun renderCodeBlock(codeBlock: FencedCodeBlock, viewportTopY: Float? = n clipboardManager.setText(AnnotatedString(codeBlock.literal.trimEnd('\n', '\r'))) showCopyFeedback = true coroutineScope.launch { - delay(2000) + delay(2000.milliseconds) showCopyFeedback = false } }, diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/util/FileDialogUtils.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/util/FileDialogUtils.kt index 3041de1e..216c501a 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/util/FileDialogUtils.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/util/FileDialogUtils.kt @@ -13,7 +13,19 @@ import io.github.vinceglb.filekit.dialogs.openDirectoryPicker import io.github.vinceglb.filekit.dialogs.openFilePicker import io.github.vinceglb.filekit.dialogs.openFileSaver import io.github.vinceglb.filekit.path +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Font import java.io.File +import javax.swing.BorderFactory +import javax.swing.JFileChooser +import javax.swing.JPanel +import javax.swing.JTextArea +import javax.swing.SwingUtilities +import javax.swing.UIManager +import javax.swing.filechooser.FileNameExtensionFilter /** * Utilities for working with file/folder pickers via FileKit. @@ -28,11 +40,130 @@ import java.io.File */ object FileDialogUtils { + /** + * Builds a hint accessory panel suitable for attaching to a [JFileChooser]. + * + * Uses a non-editable, word-wrapped [JTextArea] so the text reflows naturally when the + * dialog is resized and arbitrarily long / multi-line hints are rendered correctly. + * The panel is visually separated from the chooser body by a 1 px top border. + */ + private fun buildHintAccessory(hint: String): JPanel { + val textArea = JTextArea(hint).apply { + isEditable = false + isFocusable = false + isOpaque = false + lineWrap = true + wrapStyleWord = true + font = Font(font.name, Font.PLAIN, 11) + foreground = Color(0x60, 0x60, 0x60) + border = BorderFactory.createEmptyBorder(6, 8, 6, 8) + } + return JPanel(BorderLayout()).apply { + add(textArea, BorderLayout.CENTER) + border = BorderFactory.createMatteBorder(1, 0, 0, 0, Color(0xCC, 0xCC, 0xCC)) + } + } + /** * Opens a native folder picker and returns the selected directory path, or null if cancelled. */ suspend fun pickFolderPath(title: String): String? = FileKit.openDirectoryPicker()?.path + /** + * Opens a [JFileChooser]-based folder picker with a customisable approve-button label + * (defaults to "Select"). This avoids the platform default "Open" label that appears + * in the native NSOpenPanel / IFileOpenDialog and is therefore less confusing when the + * intent is to *select* a directory rather than open a file inside it. + * + * An optional [navigationHint] text is shown as a small accessory panel inside the + * dialog (e.g. "Double-click a folder to navigate into it") to guide novice users. + * + * Runs on the AWT Event Dispatch Thread via [SwingUtilities.invokeAndWait] to comply + * with Swing's threading model, then returns the result on the caller's coroutine context. + * + * @param title Dialog window title. + * @param approveButtonText Label shown on the confirm button (default: "Select"). + * @param navigationHint Optional hint shown at the bottom of the dialog to guide navigation. + * @return The absolute path of the chosen directory, or `null` if cancelled. + */ + suspend fun pickFolderPathWithChooser( + title: String, + approveButtonText: String = "Select", + navigationHint: String? = "Tip: Double-click a folder to open it, then click \u201c$approveButtonText\u201d to choose it", + ): String? = withContext(Dispatchers.IO) { + // JFileChooser must be shown on the AWT EDT; invokeAndWait blocks the IO thread + // until the dialog is dismissed so we can safely return the result. + var selectedPath: String? = null + SwingUtilities.invokeAndWait { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()) + } catch (_: Exception) { /* best-effort */ } + + val chooser = JFileChooser().apply { + dialogTitle = title + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + isAcceptAllFileFilterUsed = false + + if (!navigationHint.isNullOrBlank()) { + accessory = buildHintAccessory(navigationHint) + } + } + + val result = chooser.showDialog(null, approveButtonText) + if (result == JFileChooser.APPROVE_OPTION) { + selectedPath = chooser.selectedFile?.absolutePath + } + } + selectedPath + } + + /** + * Opens a [JFileChooser]-based multi-file picker with a supported-file-type filter and + * a customisable approve-button label (defaults to "Select"). + * + * This is intentionally non-native — the same UX exception applied to the folder chooser — + * so that the "Add Reference" dialog feels consistent and gives users a richer filter UI. + * + * Runs on the AWT Event Dispatch Thread via [SwingUtilities.invokeAndWait] to comply + * with Swing's threading model, then returns the result on the caller's coroutine context. + * + * @param title Dialog window title. + * @param approveButtonText Label shown on the confirm button (default: "Select"). + * @param extensions File extensions to filter (without dot). Null = show all supported types. + * @return The list of absolute paths of chosen files, or an empty list if cancelled. + */ + suspend fun pickFilePathsWithChooser( + title: String, + approveButtonText: String = "Select", + extensions: List? = FileTypeSupport.supportedExtensions(), + ): List = withContext(Dispatchers.IO) { + var selectedPaths: List = emptyList() + SwingUtilities.invokeAndWait { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()) + } catch (_: Exception) { /* best-effort */ } + + val chooser = JFileChooser().apply { + dialogTitle = title + fileSelectionMode = JFileChooser.FILES_ONLY + isMultiSelectionEnabled = true + + if (!extensions.isNullOrEmpty()) { + val extsArray = extensions.toTypedArray() + val description = extsArray.joinToString(", ") { ".$it" } + addChoosableFileFilter(FileNameExtensionFilter(description, *extsArray)) + isAcceptAllFileFilterUsed = false + } + } + + val result = chooser.showDialog(null, approveButtonText) + if (result == JFileChooser.APPROVE_OPTION) { + selectedPaths = chooser.selectedFiles.map { it.absolutePath } + } + } + selectedPaths + } + /** * Opens a native single-file picker filtered to [extensions] and returns the * selected file path, or null if cancelled. diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlansGalleryView.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlansGalleryView.kt index 09afce51..70c6f3bd 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlansGalleryView.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlansGalleryView.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -62,6 +63,9 @@ import io.askimo.ui.common.i18n.stringResource import io.askimo.ui.common.theme.AppComponents import io.askimo.ui.common.theme.Spacing import io.askimo.ui.common.theme.ThemePreferences +import io.askimo.ui.common.ui.themedTooltip +import java.awt.Desktop +import java.net.URI @Composable fun plansGalleryView( @@ -106,6 +110,22 @@ fun plansGalleryView( horizontalArrangement = Arrangement.spacedBy(Spacing.extraSmall), verticalAlignment = Alignment.CenterVertically, ) { + themedTooltip(text = stringResource("plans.docs.tooltip")) { + IconButton( + onClick = { + runCatching { + Desktop.getDesktop().browse(URI("https://askimo.chat/docs/desktop/plans/")) + } + }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + Icons.Default.Info, + contentDescription = stringResource("plans.docs.tooltip"), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } IconButton( onClick = onNewPlan, modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/project/AddReferenceMaterialDialog.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/project/AddReferenceMaterialDialog.kt index 6e101379..31fcbe35 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/project/AddReferenceMaterialDialog.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/project/AddReferenceMaterialDialog.kt @@ -58,12 +58,18 @@ fun addReferenceMaterialDialog( val browseFolderTitle = stringResource("project.new.dialog.folder.browse") val browseFileTitle = stringResource("project.new.dialog.file.browse") + val folderApproveButtonText = stringResource("file.chooser.folder.select") + val folderNavigationHint = stringResource("file.chooser.folder.hint") + val fileApproveButtonText = stringResource("file.chooser.file.select") // Shared knowledge source browser helper - val sourceBrowser = remember(browseFolderTitle, browseFileTitle) { + val sourceBrowser = remember(browseFolderTitle, browseFileTitle, folderApproveButtonText, folderNavigationHint, fileApproveButtonText) { KnowledgeSourceBrowser( browseFolderTitle = browseFolderTitle, browseFileTitle = browseFileTitle, + folderApproveButtonText = folderApproveButtonText, + folderNavigationHint = folderNavigationHint, + fileApproveButtonText = fileApproveButtonText, ) } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/project/EditProjectDialog.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/project/EditProjectDialog.kt index 202f43e6..2f981acf 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/project/EditProjectDialog.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/project/EditProjectDialog.kt @@ -184,12 +184,18 @@ private fun editProjectForm( val emptyNameError = stringResource("project.new.dialog.name.error.empty") val browseFolderTitle = stringResource("project.new.dialog.folder.browse") val browseFileTitle = stringResource("project.new.dialog.file.browse") + val folderApproveButtonText = stringResource("file.chooser.folder.select") + val folderNavigationHint = stringResource("file.chooser.folder.hint") + val fileApproveButtonText = stringResource("file.chooser.file.select") // Shared knowledge source browser helper - val sourceBrowser = remember(browseFolderTitle, browseFileTitle) { + val sourceBrowser = remember(browseFolderTitle, browseFileTitle, folderApproveButtonText, folderNavigationHint, fileApproveButtonText) { KnowledgeSourceBrowser( browseFolderTitle = browseFolderTitle, browseFileTitle = browseFileTitle, + folderApproveButtonText = folderApproveButtonText, + folderNavigationHint = folderNavigationHint, + fileApproveButtonText = fileApproveButtonText, ) } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/project/KnowledgeSourceBrowser.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/project/KnowledgeSourceBrowser.kt index e98761dc..93b8800e 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/project/KnowledgeSourceBrowser.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/project/KnowledgeSourceBrowser.kt @@ -9,16 +9,34 @@ import java.util.UUID /** * Helper class for browsing and adding knowledge sources (folders, files, URLs). + * + * Both the folder and file pickers intentionally use [JFileChooser] (non-native) rather than + * the platform-native dialog — this is a deliberate UX exception for the "Add Reference" + * workflow to provide consistent approve-button labels and richer filter controls. */ class KnowledgeSourceBrowser( private val browseFolderTitle: String, private val browseFileTitle: String, + /** Label for the confirm button in the folder chooser dialog (e.g. "Select"). */ + private val folderApproveButtonText: String = "Select", + /** Hint text shown inside the folder chooser dialog to guide navigation. */ + private val folderNavigationHint: String? = null, + /** Label for the confirm button in the file chooser dialog (e.g. "Select"). */ + private val fileApproveButtonText: String = "Select", ) { /** * Browse for a folder and return a [KnowledgeSourceItem.Folder] if selected. + * + * Uses [FileDialogUtils.pickFolderPathWithChooser] so the confirm button reads + * the localized [folderApproveButtonText] instead of the platform default "Open". */ suspend fun browseForFolder(): KnowledgeSourceItem.Folder? { - val folderPath = FileDialogUtils.pickFolderPath(browseFolderTitle) + val folderPath = FileDialogUtils.pickFolderPathWithChooser( + title = browseFolderTitle, + approveButtonText = folderApproveButtonText, + navigationHint = folderNavigationHint + ?: "Tip: Double-click a folder to open it, then click \u201c$folderApproveButtonText\u201d to choose it", + ) return folderPath?.let { KnowledgeSourceItem.Folder( id = UUID.randomUUID().toString(), @@ -29,9 +47,13 @@ class KnowledgeSourceBrowser( } /** - * Browse for files and return a list of [KnowledgeSourceItem.File]. + * Browse for files using a [JFileChooser]-based picker (non-native, consistent with + * [browseForFolder]) and return a list of [KnowledgeSourceItem.File]. */ - suspend fun browseForFiles(): List = FileDialogUtils.pickFilePaths(browseFileTitle).map { path -> + suspend fun browseForFiles(): List = FileDialogUtils.pickFilePathsWithChooser( + title = browseFileTitle, + approveButtonText = fileApproveButtonText, + ).map { path -> KnowledgeSourceItem.File( id = UUID.randomUUID().toString(), path = path, diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index 7dd1070b..645e8937 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -1131,6 +1131,7 @@ mcp.no.instances.description=Configure MCP server instances in project settings\ # Plans plans.nav.title=Plans plans.title=Plans +plans.docs.tooltip=View Plans Guide plans.description=Run AI-powered multi-step workflows plans.empty=No plans available plans.run=Run Plan @@ -1180,3 +1181,8 @@ plans.tab.my.plans.empty.hint=Duplicate a built-in plan or create your own with plans.followup.placeholder=Ask a follow-up question or request a change... plans.followup.send=Send plans.followup.label=Follow-up + +# File / folder chooser +file.chooser.folder.select=Select +file.chooser.folder.hint=Double-click a folder to enter it. When you\u2019re in the folder you want to index, click \u201cSelect\u201d. +file.chooser.file.select=Select \ No newline at end of file diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index 04e057e8..192e33a4 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -1138,6 +1138,7 @@ mcp.no.instances.description=Konfigurieren Sie MCP-Serverinstanzen in den Projek # Plans plans.nav.title=Pläne plans.title=Pläne +plans.docs.tooltip=Pläne-Anleitung ansehen plans.description=KI-gestützte mehrstufige Workflows ausführen plans.empty=Keine Pläne verfügbar plans.run=Plan ausführen @@ -1188,3 +1189,7 @@ plans.followup.placeholder=Stellen Sie eine Anschlussfrage oder bitten Sie um ei plans.followup.send=Senden plans.followup.label=Anschlussfrage +# File / folder chooser +file.chooser.folder.select=Auswählen +file.chooser.folder.hint=Doppelklicken Sie auf einen Ordner, um ihn zu öffnen. Wenn Sie sich in dem Ordner befinden, den Sie indizieren möchten, klicken Sie auf „Auswählen“. +file.chooser.file.select=Auswählen \ No newline at end of file diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index 3dfc23af..123a65bf 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -1136,6 +1136,7 @@ mcp.no.instances.description=Configure instancias del servidor MCP en la configu # Plans plans.nav.title=Planes plans.title=Planes +plans.docs.tooltip=Ver guía de planes plans.description=Ejecuta flujos de trabajo de varios pasos impulsados por IA plans.empty=No hay planes disponibles plans.run=Ejecutar plan @@ -1185,3 +1186,8 @@ plans.tab.my.plans.empty.hint=Duplica un plan integrado o crea el tuyo con + plans.followup.placeholder=Haz una pregunta de seguimiento o solicita un cambio... plans.followup.send=Enviar plans.followup.label=Seguimiento + +# File / folder chooser +file.chooser.folder.select=Seleccionar +file.chooser.folder.hint=Haz doble clic en una carpeta para entrar en ella. Cuando estés en la carpeta que quieres indexar, haz clic en “Seleccionar”. +file.chooser.file.select=Seleccionar \ No newline at end of file diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index 0638aa9c..1613194c 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -1136,6 +1136,7 @@ mcp.no.instances.description=Configurez des instances de serveur MCP dans les pa # Plans plans.nav.title=Plans plans.title=Plans +plans.docs.tooltip=Voir le guide des plans plans.description=Exécuter des workflows multi-étapes alimentés par l’IA plans.empty=Aucun plan disponible plans.run=Exécuter le plan @@ -1184,4 +1185,9 @@ plans.tab.my.plans.empty=Vous n’avez pas encore de plans plans.tab.my.plans.empty.hint=Dupliquez un plan intégré ou créez le vôtre avec + plans.followup.placeholder=Posez une question de suivi ou demandez une modification... plans.followup.send=Envoyer -plans.followup.label=Suivi \ No newline at end of file +plans.followup.label=Suivi + +# File / folder chooser +file.chooser.folder.select=Sélectionner +file.chooser.folder.hint=Double-cliquez sur un dossier pour l’ouvrir. Lorsque vous êtes dans le dossier que vous souhaitez indexer, cliquez sur « Sélectionner ». +file.chooser.file.select=Sélectionner \ No newline at end of file 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 8e6dde9e..9885842a 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -1136,6 +1136,7 @@ mcp.no.instances.description=外部ツールやサービスに接続するには # Plans plans.nav.title=プラン plans.title=プラン +plans.docs.tooltip=プランガイドを見る plans.description=AI を活用した複数ステップのワークフローを実行 plans.empty=利用可能なプランはありません plans.run=プランを実行 @@ -1185,3 +1186,8 @@ plans.tab.my.plans.empty.hint=組み込みプランを複製するか、+で新 plans.followup.placeholder=追加の質問をするか、変更を依頼してください... plans.followup.send=送信 plans.followup.label=フォローアップ + +# File / folder chooser +file.chooser.folder.select=選択 +file.chooser.folder.hint=フォルダーをダブルクリックすると、そのフォルダーに移動します。インデックスを作成したいフォルダーを開いたら、「選択」をクリックしてください。 +file.chooser.file.select=選択 \ No newline at end of file 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 6b1c522d..0ebf6815 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -1136,6 +1136,7 @@ mcp.no.instances.description=외부 도구 및 서비스에 연결하려면\n프 # Plans plans.nav.title=플랜 plans.title=플랜 +plans.docs.tooltip=플랜 가이드 보기 plans.description=AI 기반 다단계 워크플로 실행 plans.empty=사용 가능한 플랜이 없습니다 plans.run=플랜 실행 @@ -1185,3 +1186,8 @@ plans.tab.my.plans.empty.hint=기본 플랜을 복제하거나 +로 새로 만 plans.followup.placeholder=추가 질문을 하거나 변경을 요청하세요... plans.followup.send=보내기 plans.followup.label=후속 질문 + +# File / folder chooser +file.chooser.folder.select=선택 +file.chooser.folder.hint=폴더를 두 번 클릭하여 들어가세요. 인덱싱하려는 폴더로 이동한 다음 “선택”을 클릭하세요. +file.chooser.file.select=선택 \ No newline at end of file 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 589e6c1d..1c201aed 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -1136,6 +1136,7 @@ mcp.no.instances.description=Configure instâncias do servidor MCP nas configura # Plans plans.nav.title=Planos plans.title=Planos +plans.docs.tooltip=Ver guia de planos plans.description=Execute fluxos de trabalho de várias etapas com tecnologia de IA plans.empty=Nenhum plano disponível plans.run=Executar plano @@ -1185,3 +1186,8 @@ plans.tab.my.plans.empty.hint=Duplique um plano integrado ou crie o seu com + plans.followup.placeholder=Faça uma pergunta de acompanhamento ou solicite uma alteração... plans.followup.send=Enviar plans.followup.label=Acompanhamento + +# File / folder chooser +file.chooser.folder.select=Selecionar +file.chooser.folder.hint=Clique duas vezes em uma pasta para entrar nela. Quando estiver na pasta que deseja indexar, clique em “Selecionar”. +file.chooser.file.select=Selecionar \ No newline at end of file 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 24916ecb..34847624 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -1135,6 +1135,7 @@ mcp.no.instances.description=Cấu hình các phiên bản máy chủ MCP trong # Plans plans.nav.title=Kế hoạch plans.title=Kế hoạch +plans.docs.tooltip=Xem hướng dẫn kế hoạch plans.description=Chạy quy trình làm việc nhiều bước được hỗ trợ bởi AI plans.empty=Không có kế hoạch nào plans.run=Chạy kế hoạch @@ -1183,4 +1184,9 @@ plans.tab.my.plans.empty=Bạn chưa có kế hoạch nào plans.tab.my.plans.empty.hint=Nhân bản kế hoạch tích hợp sẵn hoặc tự tạo với + plans.followup.placeholder=Đặt câu hỏi tiếp theo hoặc yêu cầu thay đổi... plans.followup.send=Gửi -plans.followup.label=Trao đổi tiếp theo \ No newline at end of file +plans.followup.label=Trao đổi tiếp theo + +# File / folder chooser +file.chooser.folder.select=Chọn +file.chooser.folder.hint=Nhấp đúp vào một thư mục để mở. Khi bạn đang ở trong thư mục muốn lập chỉ mục, hãy nhấp vào “Chọn”. +file.chooser.file.select=Chọn \ No newline at end of file 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 86183e7b..a02a8790 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -1137,6 +1137,7 @@ mcp.no.instances.description=请在项目设置中配置 MCP 服务器实例,\ # Plans plans.nav.title=计划 plans.title=计划 +plans.docs.tooltip=查看计划指南 plans.description=运行由 AI 驱动的多步骤工作流 plans.empty=没有可用计划 plans.run=运行计划 @@ -1186,3 +1187,8 @@ plans.tab.my.plans.empty.hint=复制内置计划或点击 + 新建 plans.followup.placeholder=提出后续问题或请求更改... plans.followup.send=发送 plans.followup.label=后续问题 + +# File / folder chooser +file.chooser.folder.select=选择 +file.chooser.folder.hint=双击文件夹即可进入。当你进入想要建立索引的文件夹后,点击“选择”。 +file.chooser.file.select=选择 \ No newline at end of file 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 f15d0157..148eafb0 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -1138,6 +1138,7 @@ mcp.no.instances.description=請在專案設定中設定 MCP 伺服器實例,\ # Plans plans.nav.title=計畫 plans.title=計畫 +plans.docs.tooltip=查看計畫指南 plans.description=執行由 AI 驅動的多步驟工作流程 plans.empty=沒有可用的計畫 plans.run=執行計畫 @@ -1186,4 +1187,9 @@ plans.tab.my.plans.empty=還沒有自訂計劃 plans.tab.my.plans.empty.hint=複製內建計劃或點擊 + 新建 plans.followup.placeholder=提出後續問題或要求變更... plans.followup.send=傳送 -plans.followup.label=後續問題 \ No newline at end of file +plans.followup.label=後續問題 + +# File / folder chooser +file.chooser.folder.select=選取 +file.chooser.folder.hint=按兩下資料夾即可進入。當你進入想要建立索引的資料夾後,點擊「選取」。 +file.chooser.file.select=選取 \ No newline at end of file diff --git a/desktop/src/main/kotlin/io/askimo/desktop/plan/PlanEditorView.kt b/desktop/src/main/kotlin/io/askimo/desktop/plan/PlanEditorView.kt index 422c51aa..5e9ce8af 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/plan/PlanEditorView.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/plan/PlanEditorView.kt @@ -221,104 +221,112 @@ fun planEditorView( HorizontalDivider() - // ── Body: editor + hint side-by-side ────────────────────────────────── - Row( + Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 24.dp, vertical = Spacing.large), - horizontalArrangement = Arrangement.spacedBy(Spacing.large), + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, ) { - // ── Left: AI generation panel (new plans only) + YAML editor ────── - Column(modifier = Modifier.weight(1f).fillMaxSize()) { - // AI generation panel — only shown when creating a new plan - if (isNewPlan) { - aiGenerationPanel(viewModel = viewModel) - Spacer(modifier = Modifier.height(Spacing.large)) - } - - Text( - text = stringResource("plans.editor.label"), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = Spacing.small), - ) - OutlinedTextField( - value = viewModel.editorYaml, - onValueChange = { viewModel.updateEditorYaml(it) }, - modifier = Modifier.weight(1f).fillMaxWidth(), - textStyle = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - lineHeight = 20.sp, - ), - placeholder = { - Text( - text = if (isNewPlan) YAML_HINT else stringResource("plans.editor.placeholder"), - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - ) - }, - isError = viewModel.editorValidationError != null, - colors = AppComponents.outlinedTextFieldColors(), - shape = MaterialTheme.shapes.small, - ) - } - - // ── Right: hint panel + docs link ───────────────────────────────── - Column( + Row( modifier = Modifier - .widthIn(max = 320.dp) - .fillMaxSize() - .verticalScroll(scrollState), + .widthIn(max = 1400.dp) + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 24.dp, vertical = Spacing.large), + horizontalArrangement = Arrangement.spacedBy(Spacing.large), ) { - // Header row: "Reference" label + docs link - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = Spacing.small), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + // ── Left: AI generation panel (new plans only) + YAML editor ────── + Column(modifier = Modifier.weight(1f).fillMaxSize()) { + // AI generation panel — only shown when creating a new plan + if (isNewPlan) { + aiGenerationPanel(viewModel = viewModel) + Spacer(modifier = Modifier.height(Spacing.large)) + } + Text( - text = stringResource("plans.editor.hint.title"), + text = stringResource("plans.editor.label"), style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = Spacing.small), ) - linkButton( - onClick = { - runCatching { - Desktop.getDesktop().browse(URI("https://askimo.chat/docs/desktop/plans/")) - } + OutlinedTextField( + value = viewModel.editorYaml, + onValueChange = { viewModel.updateEditorYaml(it) }, + modifier = Modifier.weight(1f).fillMaxWidth(), + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + lineHeight = 20.sp, + ), + placeholder = { + Text( + text = if (isNewPlan) YAML_HINT else stringResource("plans.editor.placeholder"), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) }, + isError = viewModel.editorValidationError != null, + colors = AppComponents.outlinedTextFieldColors(), + shape = MaterialTheme.shapes.small, + ) + } + + // ── Right: hint panel + docs link ───────────────────────────────── + Column( + modifier = Modifier + .widthIn(max = 320.dp) + .fillMaxSize() + .verticalScroll(scrollState), + ) { + // Header row: "Reference" label + docs link + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = Spacing.small), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource("plans.editor.docs.link"), - style = MaterialTheme.typography.labelSmall, + text = stringResource("plans.editor.hint.title"), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + linkButton( + onClick = { + runCatching { + Desktop.getDesktop().browse(URI("https://askimo.chat/docs/desktop/plans/")) + } + }, + ) { + Text( + text = stringResource("plans.editor.docs.link"), + style = MaterialTheme.typography.labelSmall, + ) + } } - } - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.small, - modifier = Modifier.fillMaxWidth(), - ) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = YAML_HINT, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 18.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(Spacing.medium), + ) + } + Spacer(modifier = Modifier.height(Spacing.medium)) Text( - text = YAML_HINT, - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - lineHeight = 18.sp, - ), + text = stringResource("plans.editor.hint.fields"), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(Spacing.medium), + lineHeight = 20.sp, ) } - Spacer(modifier = Modifier.height(Spacing.medium)) - Text( - text = stringResource("plans.editor.hint.fields"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 20.sp, - ) } } } diff --git a/desktop/src/main/kotlin/io/askimo/desktop/shell/FooterBar.kt b/desktop/src/main/kotlin/io/askimo/desktop/shell/FooterBar.kt index 3823b3b6..2ffa9be4 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/shell/FooterBar.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/shell/FooterBar.kt @@ -425,14 +425,15 @@ fun footerBar( // Top border HorizontalDivider() - Row( + Box( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, ) { Row( + modifier = Modifier + .align(Alignment.CenterStart) + .widthIn(max = 220.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -452,7 +453,6 @@ fun footerBar( color = MaterialTheme.colorScheme.onSurface, ) } - Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -471,10 +471,13 @@ fun footerBar( } } - aiConfigInfo(onConfigureAiProvider = onConfigureAiProvider) + Box(modifier = Modifier.align(Alignment.Center)) { + aiConfigInfo(onConfigureAiProvider = onConfigureAiProvider) + } Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.align(Alignment.CenterEnd), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { themedTooltip( @@ -486,9 +489,7 @@ fun footerBar( ) { IconButton( onClick = { telemetryExpanded = !telemetryExpanded }, - modifier = Modifier - .size(28.dp) - .pointerHoverIcon(PointerIcon.Hand), + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), ) { Icon( imageVector = Icons.AutoMirrored.Filled.ShowChart, @@ -503,21 +504,21 @@ fun footerBar( } } - TextButton( - onClick = { - try { - Desktop.getDesktop().browse(URI("https://github.com/haiphucnguyen/askimo/issues")) - } catch (e: Exception) { - // Silently fail if unable to open browser - } - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) { - Text( - text = stringResource("system.share.feedback"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + themedTooltip(text = stringResource("system.share.feedback")) { + TextButton( + onClick = { + runCatching { + Desktop.getDesktop().browse(URI("https://github.com/haiphucnguyen/askimo/issues")) + } + }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) { + Text( + text = stringResource("system.share.feedback"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } } notificationIcon(onShowUpdateDetails = onShowUpdateDetails)