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.res.stringResource
23import soy.iko.opencode.R
24import soy.iko.opencode.ui.Routes
26/** A top-level destination surfaced on the large-screen navigation rail. */
27private data class RailDest(
30 val icon: ImageVector,
31 /** Whether the destination needs an active server connection. */
32 val requiresConnection: Boolean,
35private val RAIL_DESTINATIONS = listOf(
36 RailDest(Routes.SESSIONS, R.string.sessions_title, Icons.AutoMirrored.Filled.Chat, true),
37 RailDest(Routes.SEARCH, R.string.search_all_title, Icons.Filled.Search, true),
38 RailDest(Routes.FILES, R.string.files, Icons.Filled.Folder, true),
39 RailDest(Routes.USAGE, R.string.usage_title, Icons.Filled.QueryStats, true),
40 RailDest(Routes.MCP, R.string.mcp_servers, Icons.Filled.Hub, true),
41 RailDest(Routes.SETTINGS, R.string.settings, Icons.Filled.Settings, false),
42 RailDest(Routes.SERVERS, R.string.servers_title, Icons.Filled.Dns, false),
46 * A [NavigationRail] shown alongside the nav content on large screens (tablets / foldables), so
47 * the top-level destinations are one tap away instead of buried in a screen's overflow menu.
49 * @param currentRoute the current NavHost destination route, used to highlight the active item.
50 * @param connected whether a server connection is active; connection-dependent items (sessions,
51 * files, …) are disabled until one exists.
52 * @param unreadCount total unread messages across sessions, shown as a badge on the Sessions item.
53 * @param runActive whether any agent run is active, shown as a dot badge on the Sessions item.
54 * @param onNavigate invoked with the destination's route when an item is selected.
57fun LargeScreenNavRail(
58 currentRoute: String?,
60 onNavigate: (String) -> Unit,
61 modifier: Modifier = Modifier,
63 runActive: Boolean = false,
65 NavigationRail(modifier = modifier.fillMaxHeight()) {
66 RAIL_DESTINATIONS.forEach { dest ->
67 // Match exact top-level routes, and also highlight the rail item when a detail
68 // route pushed on top of it is active: chat/{id} is a detail of `sessions`, and
69 // file_view?path=… is a detail of `files`. Without this, drilling into a file
70 // loses the FILES highlight and the user loses their place in the rail.
71 val selected = currentRoute == dest.route ||
72 (dest.route == Routes.SESSIONS && currentRoute != null && currentRoute.startsWith("${Routes.CHAT}/")) ||
73 (dest.route == Routes.FILES && currentRoute != null && currentRoute.startsWith(Routes.FILE_VIEW))
74 // Badge the Sessions item with unread count and/or a running dot, so a user on
75 // Files/Settings can see at a glance that sessions need attention or a run is
76 // active — mirroring the launcher badge but in-app.
77 val showUnreadBadge = dest.route == Routes.SESSIONS && unreadCount > 0
78 val showRunBadge = dest.route == Routes.SESSIONS && runActive
81 enabled = !dest.requiresConnection || connected,
82 onClick = { onNavigate(dest.route) },
84 if (showUnreadBadge || showRunBadge) {
87 if (showUnreadBadge) {
88 Badge { Text(unreadCount.coerceAtMost(99).toString()) }
89 } else if (showRunBadge) {
93 ) { Icon(dest.icon, contentDescription = null) }
95 Icon(dest.icon, contentDescription = null)
98 label = { Text(stringResource(dest.labelRes)) },
99 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