From 6466f1429c301f4b3d2ee01c8a7359dc6f8a61cf Mon Sep 17 00:00:00 2001 From: Hai Phuc Nguyen <3423575+haiphucnguyen@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:50:05 -0700 Subject: [PATCH] refactor(ui): Update navigation sidebar and session management logic - Consolidates navigation item structure into a dedicated `SidebarNavItem` component. - Updates session manager to use compound assignment (`+=`) for adding tool calls. - Refactors `NavigationSidebar` to accept a unified list of navigation items. BREAKING CHANGE: The `navigationSidebar` composable component now accepts a single `navItems` list instead of individual parameters for navigation actions. Please update calling sites to pass items in this new list structure. --- .../io/askimo/ui/session/SessionManager.kt | 2 +- .../io/askimo/ui/shell/NavigationSidebar.kt | 233 +++++------------- .../io/askimo/ui/shell/SidebarNavItem.kt | 35 +++ .../askimo/desktop/shell/NavigationSidebar.kt | 64 ++++- 4 files changed, 145 insertions(+), 189 deletions(-) create mode 100644 desktop-shared/src/main/kotlin/io/askimo/ui/shell/SidebarNavItem.kt 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 48c94725f..75fc78335 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 @@ -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, diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NavigationSidebar.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NavigationSidebar.kt index 582cd3977..48457b294 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NavigationSidebar.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/NavigationSidebar.kt @@ -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 @@ -118,26 +116,22 @@ 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, + // 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, @@ -145,12 +139,8 @@ fun navigationSidebar( // 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, @@ -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, @@ -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, ) } @@ -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, 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, @@ -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) @@ -527,19 +407,11 @@ private fun expandedNavigationSidebar( @Composable private fun collapsedNavigationSidebar( animatedWidth: Dp, - isPlansSelected: Boolean, - isSkillsSelected: Boolean, - isProjectsSelected: Boolean, + navItems: List, 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 @@ -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(), ) @@ -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 // ───────────────────────────────────────────────────────────────────────────── @@ -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)) { diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/SidebarNavItem.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/SidebarNavItem.kt new file mode 100644 index 000000000..d2a271d4c --- /dev/null +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/SidebarNavItem.kt @@ -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, +) diff --git a/desktop/src/main/kotlin/io/askimo/desktop/shell/NavigationSidebar.kt b/desktop/src/main/kotlin/io/askimo/desktop/shell/NavigationSidebar.kt index 41066d35e..09c40b645 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/shell/NavigationSidebar.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/shell/NavigationSidebar.kt @@ -15,11 +15,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PlayCircle import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Text @@ -45,6 +50,7 @@ import io.askimo.ui.common.theme.AppTextStyles import io.askimo.ui.common.theme.LocalFontScale import io.askimo.ui.common.ui.themedTooltip import io.askimo.ui.session.SessionsViewModel +import io.askimo.ui.shell.SidebarNavItem import io.askimo.ui.shell.rememberAvatarImage import io.askimo.ui.shell.sidebarUserAvatar import io.askimo.ui.shell.navigationSidebar as sharedNavigationSidebar @@ -88,29 +94,65 @@ fun navigationSidebar( onNavigateToSkills: () -> Unit = {}, onNavigateToDiscover: () -> Unit = {}, ) { + val isProjectsSelected = currentView == View.PROJECTS + val navItems = listOf( + SidebarNavItem( + id = "projects", + labelRes = "project.title", + icon = Icons.Default.FolderOpen, + isSelected = isProjectsSelected, + isVisible = showProjectsInSidebar, + onClick = onToggleProjects, + badge = { isHovered -> + if (isHovered) { + val bfs = LocalFontScale.current + themedTooltip(text = stringResource("project.new.dialog.title")) { + IconButton( + onClick = onNewProject, + modifier = Modifier.size((24 * bfs).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 * bfs).dp), + ) + } + } + } + }, + ), + SidebarNavItem( + id = "plans", + labelRes = "plans.nav.title", + icon = Icons.Default.PlayCircle, + isSelected = currentView == View.PLANS || currentView == View.PLAN_DETAIL, + isVisible = showPlansInSidebar, + onClick = onNavigateToPlans, + ), + SidebarNavItem( + id = "skills", + labelRes = "skills.nav.title", + icon = Icons.Default.Extension, + isSelected = currentView == View.SKILLS, + isVisible = showSkillsInSidebar, + onClick = onNavigateToSkills, + ), + ) sharedNavigationSidebar( isExpanded = isExpanded, width = width, - isPlansSelected = currentView == View.PLANS || currentView == View.PLAN_DETAIL, - isSkillsSelected = currentView == View.SKILLS, - isProjectsSelected = currentView == View.PROJECTS, - isSessionsSelected = currentView == View.SESSIONS, - showPlansInSidebar = showPlansInSidebar, - showSkillsInSidebar = showSkillsInSidebar, - showProjectsInSidebar = showProjectsInSidebar, + navItems = navItems, isSessionsExpanded = isSessionsExpanded, + isSessionsSelected = currentView == View.SESSIONS, projectsState = projectsViewModel, pinnedState = sessionsViewModel, sessionsViewModel = sessionsViewModel, currentSessionId = currentSessionId, onToggleExpand = onToggleExpand, onNewChat = onNewChat, - onNavigateToProjects = onToggleProjects, onToggleSessions = onToggleSessions, onNavigateToSessions = onNavigateToSessions, - onNavigateToPlans = onNavigateToPlans, - onNavigateToSkills = onNavigateToSkills, - onNewProject = onNewProject, onSelectProject = onSelectProject, onResumeSession = onResumeSession, onDeleteSession = onDeleteSession,