1package soy.iko.opencode.ui.components
3import androidx.compose.foundation.layout.fillMaxWidth
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.Search
9import androidx.compose.material.icons.filled.Settings
10import androidx.compose.material3.Badge
11import androidx.compose.material3.BadgedBox
12import androidx.compose.material3.Icon
13import androidx.compose.material3.NavigationBar
14import androidx.compose.material3.NavigationBarItem
15import androidx.compose.material3.NavigationBarItemDefaults
16import androidx.compose.material3.Text
17import androidx.compose.runtime.Composable
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.graphics.vector.ImageVector
20import androidx.compose.ui.hapticfeedback.HapticFeedbackType
21import androidx.compose.ui.platform.LocalHapticFeedback
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 compact-screen bottom navigation bar. Mirrors
27 * [RailDest] in [LargeScreenNavRail] but is kept separate so the two bars can diverge — a
28 * bottom bar is limited to 3-5 items for thumb reach, while the rail can show all seven. */
29private data class BottomDest(
32 val icon: ImageVector,
33 val requiresConnection: Boolean,
36// Five items: the most-used destinations that make sense from anywhere in the app. Servers and
37// Usage/MCP stay in the session list / settings overflow menus to avoid overcrowding the bar.
38private val BOTTOM_DESTINATIONS = listOf(
39 BottomDest(Routes.SESSIONS, R.string.sessions_title, Icons.AutoMirrored.Filled.Chat, true),
40 BottomDest(Routes.SEARCH, R.string.search_all_title, Icons.Filled.Search, true),
41 BottomDest(Routes.FILES, R.string.files, Icons.Filled.Folder, true),
42 BottomDest(Routes.SETTINGS, R.string.settings, Icons.Filled.Settings, false),
43 BottomDest(Routes.SERVERS, R.string.servers_title, Icons.Filled.Dns, false),
47 * A Material 3 [NavigationBar] shown at the bottom of compact-screen layouts (phones), so the
48 * top-level destinations are one tap away instead of buried in a screen's overflow menu. Mirrors
49 * the large-screen [LargeScreenNavRail] — the two are mutually exclusive based on window width.
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 are disabled.
53 * @param unreadCount total unread messages across sessions, shown as a badge on the Sessions item.
54 * @param runActive whether any agent run is active, shown as a dot badge on the Sessions item.
55 * @param onNavigate invoked with the destination's route when an item is selected.
59 currentRoute: String?,
61 onNavigate: (String) -> Unit,
62 modifier: Modifier = Modifier,
64 runActive: Boolean = false,
66 NavigationBar(modifier = modifier.fillMaxWidth()) {
67 val haptics = LocalHapticFeedback.current
68 BOTTOM_DESTINATIONS.forEach { dest ->
69 val selected = currentRoute == dest.route ||
70 (dest.route == Routes.SESSIONS && currentRoute != null &&
71 currentRoute.startsWith("${Routes.CHAT}/")) ||
72 (dest.route == Routes.FILES && currentRoute != null &&
73 currentRoute.startsWith(Routes.FILE_VIEW))
74 val showUnreadBadge = dest.route == Routes.SESSIONS && unreadCount > 0
75 val showRunBadge = dest.route == Routes.SESSIONS && runActive
78 enabled = !dest.requiresConnection || connected,
80 // Haptic on selection to match the app's haptic-everywhere ethos
81 // (NavigationBarItem adds none by default). Gated by the root haptics
82 // CompositionLocal, so the in-app toggle still silences it.
83 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
84 onNavigate(dest.route)
87 if (showUnreadBadge || showRunBadge) {
90 if (showUnreadBadge) {
91 Badge { Text(unreadCount.coerceAtMost(99).toString()) }
92 } else if (showRunBadge) {
96 ) { Icon(dest.icon, contentDescription = null) }
98 Icon(dest.icon, contentDescription = null)
101 label = { Text(stringResource(dest.labelRes)) },
102 colors = NavigationBarItemDefaults.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