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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import io.askimo.ui.common.preferences.ApplicationPreferences
import io.askimo.ui.common.theme.AppComponents
import io.askimo.ui.common.theme.LocalBackgroundActive
import io.askimo.ui.common.theme.ThemePreferences
import io.askimo.ui.common.ui.TooltipPlacement
import io.askimo.ui.common.ui.themedTooltip
import io.askimo.ui.service.AvatarService
import io.askimo.ui.session.manageDirectivesDialog
Expand Down Expand Up @@ -494,10 +495,10 @@ fun chatView(

// Session title
themedTooltip(
text = sessionTitle ?: "New Chat",
text = sessionTitle,
) {
Text(
text = sessionTitle ?: "New Chat",
text = sessionTitle,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
Expand Down Expand Up @@ -597,6 +598,7 @@ fun chatView(
availableDirectives.forEach { directive ->
themedTooltip(
text = "${directive.name}\n${directive.content}",
placement = TooltipPlacement.LEFT,
) {
DropdownMenuItem(
text = {
Expand Down Expand Up @@ -706,7 +708,7 @@ fun chatView(
}
}

if (sessionId != null) {
if (sessionId != null && messages.isNotEmpty()) {
sessionActionsMenu(
sessionId = sessionId,
onRenameSession = onRenameSession,
Expand Down
49 changes: 36 additions & 13 deletions desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/Tooltip.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ import kotlinx.coroutines.delay
/** Maximum number of characters shown in a tooltip before truncating with "…". */
const val TOOLTIP_MAX_CHARS = 1200

/** Controls which side the tooltip appears on relative to its anchor. */
enum class TooltipPlacement {
/** Default: above when in bottom half of screen, below otherwise. */
AUTO,

/** Always to the left of the anchor. */
LEFT,

/** Always to the right of the anchor. */
RIGHT,
}

/**
* A reusable tooltip component that follows the application theme.
* Automatically positions itself to avoid overlapping with the component and screen edges.
Expand All @@ -47,6 +59,7 @@ const val TOOLTIP_MAX_CHARS = 1200
*
* @param text The text to display in the tooltip
* @param maxChars Maximum characters to show before truncating (default [TOOLTIP_MAX_CHARS])
* @param placement Controls where the tooltip appears relative to the anchor (default [TooltipPlacement.AUTO])
* @param modifier Optional modifier for the TooltipBox
* @param content The composable content that the tooltip wraps
*/
Expand All @@ -55,6 +68,7 @@ const val TOOLTIP_MAX_CHARS = 1200
fun themedTooltip(
text: String,
maxChars: Int = TOOLTIP_MAX_CHARS,
placement: TooltipPlacement = TooltipPlacement.AUTO,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Expand Down Expand Up @@ -84,8 +98,8 @@ fun themedTooltip(

Box {
TooltipBox(
positionProvider = remember(containerHeight) {
SmartTooltipPositionProvider(maxHeightPx = containerHeight)
positionProvider = remember(containerHeight, placement) {
SmartTooltipPositionProvider(maxHeightPx = containerHeight, placement = placement)
},
tooltip = {
Surface(
Expand Down Expand Up @@ -130,6 +144,7 @@ fun themedTooltip(
*/
private class SmartTooltipPositionProvider(
private val maxHeightPx: Float,
private val placement: TooltipPlacement = TooltipPlacement.AUTO,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
Expand All @@ -139,21 +154,29 @@ private class SmartTooltipPositionProvider(
): IntOffset {
val spacing = 8

val isInBottomHalf = anchorBounds.top > maxHeightPx / 2

return when {
isInBottomHalf -> IntOffset(
x = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2,
y = anchorBounds.top - popupContentSize.height - spacing,
)
!isInBottomHalf -> IntOffset(
x = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2,
y = anchorBounds.bottom + spacing,
return when (placement) {
TooltipPlacement.LEFT -> IntOffset(
x = anchorBounds.left - popupContentSize.width - spacing,
y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2,
)
else -> IntOffset(
TooltipPlacement.RIGHT -> IntOffset(
x = anchorBounds.right + spacing,
y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2,
)
TooltipPlacement.AUTO -> {
val isInBottomHalf = anchorBounds.top > maxHeightPx / 2
if (isInBottomHalf) {
IntOffset(
x = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2,
y = anchorBounds.top - popupContentSize.height - spacing,
)
} else {
IntOffset(
x = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2,
y = anchorBounds.bottom + spacing,
)
}
}
}
}
}
151 changes: 80 additions & 71 deletions desktop-shared/src/main/kotlin/io/askimo/ui/project/ProjectView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ScrollbarStyle
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -176,14 +178,24 @@ fun projectView(
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.Top,
) {
Text(
text = currentProject.name,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) {
Text(
text = currentProject.name,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
currentProject.description?.takeIf { it.isNotBlank() }?.let { desc ->
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}

Box {
themedTooltip(text = stringResource("project.menu.tooltip")) {
Expand Down Expand Up @@ -227,16 +239,6 @@ fun projectView(
}
}

// Project Description (if exists)
currentProject.description?.let { desc ->
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
}

// Knowledge Sources Panel
knowledgeSourcesPanel(
currentProject = currentProject,
Expand Down Expand Up @@ -405,10 +407,13 @@ private fun sessionCard(
var showMenu by remember { mutableStateOf(false) }
var showNewProjectDialog by remember { mutableStateOf(false) }
var sessionIdToMove by remember { mutableStateOf<String?>(null) }
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()

Card(
modifier = Modifier
.fillMaxWidth()
.hoverable(interactionSource)
.clickableCard(cornerRadius = 8.dp, onClick = onClick),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
Expand Down Expand Up @@ -436,68 +441,72 @@ private fun sessionCard(
Column(
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
) {
Text(
text = session.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
themedTooltip(text = session.title) {
Text(
text = session.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = TimeUtil.formatDisplay(session.updatedAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

Box {
IconButton(
onClick = { showMenu = true },
modifier = Modifier
.size(24.dp)
.pointerHoverIcon(PointerIcon.Hand),
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More options",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
if (isHovered || showMenu) {
Box {
IconButton(
onClick = { showMenu = true },
modifier = Modifier
.size(24.dp)
.pointerHoverIcon(PointerIcon.Hand),
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More options",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}

AppComponents.dropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
SessionActionMenu.projectViewMenu(
sessionId = session.id,
currentProjectId = currentProject.id,
currentProjectName = currentProject.name,
availableProjects = allProjects,
onExport = { onExportSession(session.id) },
onRename = { onRenameSession(session.id, session.title) },
onDelete = { onDeleteSession(session.id) },
onMoveToNewProject = {
sessionIdToMove = session.id
showNewProjectDialog = true
},
onMoveToExistingProject = { selectedProject ->
viewModel.moveSessionToProject(session.id, selectedProject.id)
},
onRemoveFromProject = {
viewModel.removeSessionFromProject(session.id)
// Refresh global sessions list (session now appears in "All Sessions")
EventBus.post(
SessionsRefreshEvent(
reason = "Session ${session.id} removed from project",
),
)
},
onDismiss = { showMenu = false },
)
}
}
AppComponents.dropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
SessionActionMenu.projectViewMenu(
sessionId = session.id,
currentProjectId = currentProject.id,
currentProjectName = currentProject.name,
availableProjects = allProjects,
onExport = { onExportSession(session.id) },
onRename = { onRenameSession(session.id, session.title) },
onDelete = { onDeleteSession(session.id) },
onMoveToNewProject = {
sessionIdToMove = session.id
showNewProjectDialog = true
},
onMoveToExistingProject = { selectedProject ->
viewModel.moveSessionToProject(session.id, selectedProject.id)
},
onRemoveFromProject = {
viewModel.removeSessionFromProject(session.id)
// Refresh global sessions list (session now appears in "All Sessions")
EventBus.post(
SessionsRefreshEvent(
reason = "Session ${session.id} removed from project",
),
)
},
onDismiss = { showMenu = false },
)
}
} // end Box
} // end if (isHovered || showMenu)
}
}

Expand Down
Loading