1package soy.iko.opencode.ui.components
3import androidx.compose.foundation.layout.fillMaxHeight
4import androidx.compose.material.icons.Icons
5import androidx.compose.material.icons.automirrored.filled.Chat
6import androidx.compose.material.icons.filled.Dns
7import androidx.compose.material.icons.filled.Folder
8import androidx.compose.material.icons.filled.Hub
9import androidx.compose.material.icons.filled.QueryStats
10import androidx.compose.material.icons.filled.Search
11import androidx.compose.material.icons.filled.Settings
12import androidx.compose.material3.Badge
13import androidx.compose.material3.BadgedBox
14import androidx.compose.material3.Icon
15import androidx.compose.material3.NavigationRail
16import androidx.compose.material3.NavigationRailItem
17import androidx.compose.material3.NavigationRailItemDefaults
18import androidx.compose.material3.Text
19import androidx.compose.runtime.Composable
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.graphics.vector.ImageVector
22import androidx.compose.ui.hapticfeedback.HapticFeedbackType
23import androidx.compose.ui.platform.LocalHapticFeedback
24import androidx.compose.ui.res.stringResource
25import soy.iko.opencode.R
26import soy.iko.opencode.ui.Routes
28/** A top-level destination surfaced on the large-screen navigation rail. */
29private data class RailDest(
32 val icon: ImageVector,
33 /** Whether the destination needs an active server connection. */
34 val requiresConnection: Boolean,
37private val RAIL_DESTINATIONS = listOf(
38 RailDest(Routes.SESSIONS, R.string.sessions_title, Icons.AutoMirrored.Filled.Chat, true),
39 RailDest(Routes.SEARCH, R.string.search_all_title, Icons.Filled.Search, true),
40 RailDest(Routes.FILES, R.string.files, Icons.Filled.Folder, true),
41 RailDest(Routes.USAGE, R.string.usage_title, Icons.Filled.QueryStats, true),
42 RailDest(Routes.MCP, R.string.mcp_servers, Icons.Filled.Hub, true),
43 RailDest(Routes.SETTINGS, R.string.settings, Icons.Filled.Settings, false),
44 RailDest(Routes.SERVERS, R.string.servers_title, Icons.Filled.Dns, false),
48 * A [NavigationRail] shown alongside the nav content on large screens (tablets / foldables), so
49 * the top-level destinations are one tap away instead of buried in a screen's overflow menu.
51 * @param currentRoute the current NavHost destination route, used to highlight the active item.
52 * @param connected whether a server connection is active; connection-dependent items (sessions,
53 * files, …) are disabled until one exists.
54 * @param unreadCount total unread messages across sessions, shown as a badge on the Sessions item.
55 * @param runActive whether any agent run is active, shown as a dot badge on the Sessions item.
56 * @param onNavigate invoked with the destination's route when an item is selected.
59fun LargeScreenNavRail(
60 currentRoute: String?,
62 onNavigate: (String) -> Unit,
63 modifier: Modifier = Modifier,
65 runActive: Boolean = false,
67 NavigationRail(modifier = modifier.fillMaxHeight()) {
68 val haptics = LocalHapticFeedback.current
69 RAIL_DESTINATIONS.forEach { dest ->
70 // Match exact top-level routes, and also highlight the rail item when a detail
71 // route pushed on top of it is active: chat/{id} is a detail of `sessions`, and
72 // file_view?path=… is a detail of `files`. Without this, drilling into a file
73 // loses the FILES highlight and the user loses their place in the rail.
74 val selected = currentRoute == dest.route ||
75 (dest.route == Routes.SESSIONS && currentRoute != null && currentRoute.startsWith("${Routes.CHAT}/")) ||
76 (dest.route == Routes.FILES && currentRoute != null && currentRoute.startsWith(Routes.FILE_VIEW))
77 // Badge the Sessions item with unread count and/or a running dot, so a user on
78 // Files/Settings can see at a glance that sessions need attention or a run is
79 // active — mirroring the launcher badge but in-app.
80 val showUnreadBadge = dest.route == Routes.SESSIONS && unreadCount > 0
81 val showRunBadge = dest.route == Routes.SESSIONS && runActive
84 enabled = !dest.requiresConnection || connected,
86 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
87 onNavigate(dest.route)
90 if (showUnreadBadge || showRunBadge) {
93 if (showUnreadBadge) {
94 Badge { Text(unreadCount.coerceAtMost(99).toString()) }
95 } else if (showRunBadge) {
99 ) { Icon(dest.icon, contentDescription = null) }
101 Icon(dest.icon, contentDescription = null)
104 label = { Text(stringResource(dest.labelRes)) },
105 colors = NavigationRailItemDefaults.colors(),
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list