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
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ class SessionManager(
suspend fun markToolRunning(toolName: String, arguments: String?) {
mutex.withLock {
if (_toolCalls.value.none { it.toolName == toolName }) {
_toolCalls.value = _toolCalls.value + ToolCallInfo(
_toolCalls.value += ToolCallInfo(
toolName = toolName,
status = ToolCallStatus.RUNNING,
arguments = arguments,
Expand Down
233 changes: 56 additions & 177 deletions desktop-shared/src/main/kotlin/io/askimo/ui/shell/NavigationSidebar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,10 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.CircularProgressIndicator
Expand Down Expand Up @@ -118,39 +116,31 @@ interface PinnedSidebarState {
* Shared navigation sidebar component with collapsible/expandable functionality.
*
* The caller is responsible for two variable parts:
* - Selection state: pass `isXxxSelected` booleans derived from the module's own View enum
* - User profile footer: pass a `userProfileContent` composable slot
* - Nav items: pass an ordered [navItems] list — each entry controls icon, label, selection,
* visibility, click action, and an optional hover-sensitive badge composable.
* - User profile footer: pass a [userProfileContent] composable slot.
*
* This keeps the sidebar decoupled from module-specific View enums and auth concerns
* This keeps the sidebar decoupled from module-specific View enums and auth concerns.
*/
@Composable
fun navigationSidebar(
isExpanded: Boolean,
width: Dp,
// Selection state — callers derive these from their own View enum
isPlansSelected: Boolean = false,
isSkillsSelected: Boolean = false,
isProjectsSelected: Boolean = false,
// Primary nav items — ordered, visibility-controlled, injected by caller
navItems: List<SidebarNavItem>,
// Session state
isSessionsExpanded: Boolean,
isSessionsSelected: Boolean = false,
// Visibility toggles controlled from View menu
showPlansInSidebar: Boolean = true,
showSkillsInSidebar: Boolean = true,
showProjectsInSidebar: Boolean = true,
// Session/project state
isSessionsExpanded: Boolean,
projectsState: ProjectsSidebarState,
pinnedState: PinnedSidebarState,
sessionsViewModel: SessionsViewModel,
currentSessionId: String?,
// Actions
onToggleExpand: () -> Unit,
onNewChat: () -> Unit,
onNavigateToProjects: () -> Unit = {},
onToggleSessions: () -> Unit,
onNavigateToSessions: () -> Unit,
onNavigateToPlans: () -> Unit = {},
onNavigateToSkills: () -> Unit = {},
onNewProject: () -> Unit = {},
onSelectProject: (String) -> Unit = {},
onResumeSession: (String) -> Unit,
onDeleteSession: (String) -> Unit,
Expand All @@ -174,26 +164,17 @@ fun navigationSidebar(
if (isExpanded) {
expandedNavigationSidebar(
animatedWidth = animatedWidth,
isPlansSelected = isPlansSelected,
isSkillsSelected = isSkillsSelected,
isProjectsSelected = isProjectsSelected,
isSessionsSelected = isSessionsSelected,
showPlansInSidebar = showPlansInSidebar,
showSkillsInSidebar = showSkillsInSidebar,
showProjectsInSidebar = showProjectsInSidebar,
navItems = navItems,
isSessionsExpanded = isSessionsExpanded,
isSessionsSelected = isSessionsSelected,
projectsState = projectsState,
pinnedState = pinnedState,
sessionsViewModel = sessionsViewModel,
currentSessionId = currentSessionId,
onToggleExpand = onToggleExpand,
onNewChat = onNewChat,
onNavigateToProjects = onNavigateToProjects,
onToggleSessions = onToggleSessions,
onNavigateToSessions = onNavigateToSessions,
onNavigateToPlans = onNavigateToPlans,
onNavigateToSkills = onNavigateToSkills,
onNewProject = onNewProject,
onSelectProject = onSelectProject,
onResumeSession = onResumeSession,
onDeleteSession = onDeleteSession,
Expand All @@ -210,19 +191,11 @@ fun navigationSidebar(
} else {
collapsedNavigationSidebar(
animatedWidth = animatedWidth,
isPlansSelected = isPlansSelected,
isSkillsSelected = isSkillsSelected,
isProjectsSelected = isProjectsSelected,
navItems = navItems,
isSessionsSelected = isSessionsSelected,
showPlansInSidebar = showPlansInSidebar,
showSkillsInSidebar = showSkillsInSidebar,
showProjectsInSidebar = showProjectsInSidebar,
onToggleExpand = onToggleExpand,
onNewChat = onNewChat,
onNavigateToProjects = onNavigateToProjects,
onNavigateToSessions = onNavigateToSessions,
onNavigateToPlans = onNavigateToPlans,
onNavigateToSkills = onNavigateToSkills,
userProfileContent = userProfileContent,
)
}
Expand All @@ -235,26 +208,17 @@ fun navigationSidebar(
@Composable
private fun expandedNavigationSidebar(
animatedWidth: Dp,
isPlansSelected: Boolean,
isSkillsSelected: Boolean,
isProjectsSelected: Boolean,
isSessionsSelected: Boolean,
showPlansInSidebar: Boolean,
showSkillsInSidebar: Boolean,
showProjectsInSidebar: Boolean,
navItems: List<SidebarNavItem>,
isSessionsExpanded: Boolean,
isSessionsSelected: Boolean,
projectsState: ProjectsSidebarState,
pinnedState: PinnedSidebarState,
sessionsViewModel: SessionsViewModel,
currentSessionId: String?,
onToggleExpand: () -> Unit,
onNewChat: () -> Unit,
onNavigateToProjects: () -> Unit,
onToggleSessions: () -> Unit,
onNavigateToSessions: () -> Unit,
onNavigateToPlans: () -> Unit,
onNavigateToSkills: () -> Unit,
onNewProject: () -> Unit,
onSelectProject: (String) -> Unit,
onResumeSession: (String) -> Unit,
onDeleteSession: (String) -> Unit,
Expand Down Expand Up @@ -334,93 +298,9 @@ private fun expandedNavigationSidebar(
)
}

// Projects
if (showProjectsInSidebar) {
val projectsInteractionSource = remember { MutableInteractionSource() }
val isProjectsHovered by projectsInteractionSource.collectIsHoveredAsState()

Box(
modifier = Modifier
.padding(horizontal = Spacing.medium)
.hoverable(projectsInteractionSource),
) {
NavigationDrawerItem(
icon = { Icon(Icons.Default.FolderOpen, contentDescription = null) },
label = {
Text(
stringResource("project.title"),
style = AppTextStyles.groupTitle,
color = if (isProjectsSelected) MaterialTheme.colorScheme.onPrimaryContainer else AppTextStyles.primaryContent,
)
},
selected = isProjectsSelected,
onClick = onNavigateToProjects,
badge = {
if (isProjectsHovered) {
themedTooltip(text = stringResource("project.new.dialog.title")) {
IconButton(
onClick = onNewProject,
modifier = Modifier
.size((24 * fontScale).dp)
.pointerHoverIcon(PointerIcon.Hand),
) {
Icon(
Icons.Default.Add,
contentDescription = stringResource("project.new.dialog.title"),
tint = if (isProjectsSelected) MaterialTheme.colorScheme.onPrimaryContainer else AppTextStyles.secondaryContent,
modifier = Modifier.size((18 * fontScale).dp),
)
}
}
}
},
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
shape = AppComponents.navigationItemShape,
colors = AppComponents.navigationDrawerItemColors(),
)
}
}

// Plans
if (showPlansInSidebar) {
NavigationDrawerItem(
icon = { Icon(Icons.Default.PlayCircle, contentDescription = null) },
label = {
Text(
stringResource("plans.nav.title"),
style = AppTextStyles.groupTitle,
color = if (isPlansSelected) MaterialTheme.colorScheme.onPrimaryContainer else AppTextStyles.primaryContent,
)
},
selected = isPlansSelected,
onClick = onNavigateToPlans,
modifier = Modifier
.padding(horizontal = Spacing.medium)
.pointerHoverIcon(PointerIcon.Hand),
shape = AppComponents.navigationItemShape,
colors = AppComponents.navigationDrawerItemColors(),
)
}

// Skills
if (showSkillsInSidebar) {
NavigationDrawerItem(
icon = { Icon(Icons.Default.Extension, contentDescription = null) },
label = {
Text(
stringResource("skills.nav.title"),
style = AppTextStyles.groupTitle,
color = if (isSkillsSelected) MaterialTheme.colorScheme.onPrimaryContainer else AppTextStyles.primaryContent,
)
},
selected = isSkillsSelected,
onClick = onNavigateToSkills,
modifier = Modifier
.padding(horizontal = Spacing.medium)
.pointerHoverIcon(PointerIcon.Hand),
shape = AppComponents.navigationItemShape,
colors = AppComponents.navigationDrawerItemColors(),
)
// Primary nav items (injected by caller — order and visibility controlled externally)
navItems.filter { it.isVisible }.forEach { item ->
sidebarNavItemRow(item = item)
}

// Pinned section (starred projects + starred sessions)
Expand Down Expand Up @@ -527,19 +407,11 @@ private fun expandedNavigationSidebar(
@Composable
private fun collapsedNavigationSidebar(
animatedWidth: Dp,
isPlansSelected: Boolean,
isSkillsSelected: Boolean,
isProjectsSelected: Boolean,
navItems: List<SidebarNavItem>,
isSessionsSelected: Boolean,
showPlansInSidebar: Boolean,
showSkillsInSidebar: Boolean,
showProjectsInSidebar: Boolean,
onToggleExpand: () -> Unit,
onNewChat: () -> Unit,
onNavigateToProjects: () -> Unit,
onNavigateToSessions: () -> Unit,
onNavigateToPlans: () -> Unit,
onNavigateToSkills: () -> Unit,
userProfileContent: @Composable () -> Unit,
) {
val fontScale = LocalFontScale.current
Expand Down Expand Up @@ -607,39 +479,13 @@ private fun collapsedNavigationSidebar(
)
}

themedTooltip(text = stringResource("project.title")) {
if (showProjectsInSidebar) {
navItems.filter { it.isVisible }.forEach { item ->
themedTooltip(text = stringResource(item.labelRes)) {
NavigationRailItem(
icon = { Icon(Icons.Default.FolderOpen, contentDescription = stringResource("project.title")) },
icon = { Icon(item.icon, contentDescription = stringResource(item.labelRes)) },
label = null,
selected = isProjectsSelected,
onClick = onNavigateToProjects,
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
colors = AppComponents.navigationRailItemColors(),
)
}
}

themedTooltip(text = stringResource("plans.nav.title")) {
if (showPlansInSidebar) {
NavigationRailItem(
icon = { Icon(Icons.Default.PlayCircle, contentDescription = stringResource("plans.nav.title")) },
label = null,
selected = isPlansSelected,
onClick = onNavigateToPlans,
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
colors = AppComponents.navigationRailItemColors(),
)
}
}

themedTooltip(text = stringResource("skills.nav.title")) {
if (showSkillsInSidebar) {
NavigationRailItem(
icon = { Icon(Icons.Default.Extension, contentDescription = stringResource("skills.nav.title")) },
label = null,
selected = isSkillsSelected,
onClick = onNavigateToSkills,
selected = item.isSelected,
onClick = item.onClick,
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
colors = AppComponents.navigationRailItemColors(),
)
Expand All @@ -663,6 +509,39 @@ private fun collapsedNavigationSidebar(
}
}

// ─────────────────────────────────────────────────────────────────────────────
// Nav item row (expanded sidebar)
// ─────────────────────────────────────────────────────────────────────────────

@Composable
private fun sidebarNavItemRow(item: SidebarNavItem) {
val interactionSource = remember(item.id) { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()

Box(
modifier = Modifier
.padding(horizontal = Spacing.medium)
.hoverable(interactionSource),
) {
NavigationDrawerItem(
icon = { Icon(item.icon, contentDescription = null) },
label = {
Text(
stringResource(item.labelRes),
style = AppTextStyles.groupTitle,
color = if (item.isSelected) MaterialTheme.colorScheme.onPrimaryContainer else AppTextStyles.primaryContent,
)
},
selected = item.isSelected,
onClick = item.onClick,
badge = item.badge?.let { badgeFn -> { badgeFn(isHovered) } },
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
shape = AppComponents.navigationItemShape,
colors = AppComponents.navigationDrawerItemColors(),
)
}
}

// ─────────────────────────────────────────────────────────────────────────────
// Logo helpers
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -912,9 +791,9 @@ private fun pinnedSessionItem(
isSelected = isSelected,
isChatInProgress = isChatInProgress,
isHovered = isHovered || showMenu,
bookmarkCount = bookmarkCount,
onResumeSession = onResumeSession,
onMenuClick = { showMenu = true },
bookmarkCount = bookmarkCount,
)

Box(modifier = Modifier.align(Alignment.CenterEnd).padding(end = Spacing.small)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* SPDX-License-Identifier: AGPLv3
*
* Copyright (c) 2026 Askimo
*/
package io.askimo.ui.shell

import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector

/**
* Descriptor for a primary navigation item in the sidebar.
*
* Callers build an ordered [List] of these and pass it to [navigationSidebar].
* The sidebar renders them in list order, giving callers full control over
* which items appear and in what sequence without touching shared code.
*
* @param id Stable key — used as a [androidx.compose.runtime.remember] key for hover state.
* @param labelRes i18n resource key for the item label and collapsed tooltip.
* @param icon Material icon vector.
* @param isSelected Whether this item is currently active/highlighted.
* @param isVisible When false the item is omitted entirely from the sidebar.
* @param onClick Navigation action invoked when the item is clicked.
* @param badge Optional trailing composable rendered inside the item's badge slot
* (expanded sidebar only). Receives [isHovered] so callers can show
* hover-only actions (e.g. a "+" button on the Projects entry).
*/
data class SidebarNavItem(
val id: String,
val labelRes: String,
val icon: ImageVector,
val isSelected: Boolean,
val isVisible: Boolean = true,
val onClick: () -> Unit,
val badge: (@Composable (isHovered: Boolean) -> Unit)? = null,
)
Loading