1package soy.iko.opencode.ui
3import androidx.compose.animation.EnterTransition
4import androidx.compose.animation.ExitTransition
5import androidx.compose.animation.core.tween
6import androidx.compose.animation.fadeIn
7import androidx.compose.animation.fadeOut
8import androidx.compose.animation.slideInHorizontally
9import androidx.compose.animation.slideOutHorizontally
10import androidx.compose.foundation.layout.Box
11import androidx.compose.foundation.layout.BoxWithConstraints
12import androidx.compose.foundation.layout.Row
13import androidx.compose.foundation.layout.fillMaxHeight
14import androidx.compose.foundation.layout.fillMaxSize
15import androidx.compose.runtime.Composable
16import androidx.compose.runtime.CompositionLocalProvider
17import androidx.compose.runtime.LaunchedEffect
18import androidx.compose.runtime.getValue
19import androidx.compose.runtime.mutableStateOf
20import androidx.compose.runtime.remember
21import androidx.compose.runtime.rememberCoroutineScope
22import androidx.compose.runtime.saveable.rememberSaveable
23import androidx.compose.runtime.setValue
24import androidx.compose.ui.Modifier
25import androidx.compose.ui.input.key.Key
26import androidx.compose.ui.input.key.KeyEventType
27import androidx.compose.ui.input.key.isCtrlPressed
28import androidx.compose.ui.input.key.isAltPressed
29import androidx.compose.ui.input.key.key
30import androidx.compose.ui.input.key.onPreviewKeyEvent
31import androidx.compose.ui.input.key.type
32import androidx.compose.ui.platform.LocalContext
33import androidx.compose.ui.platform.LocalDensity
34import androidx.compose.ui.unit.dp
35import androidx.compose.ui.unit.LayoutDirection
36import androidx.compose.ui.platform.LocalLayoutDirection
37import androidx.navigation.NavType
38import androidx.navigation.NavGraph.Companion.findStartDestination
39import androidx.navigation.compose.NavHost
40import androidx.navigation.compose.composable
41import androidx.navigation.compose.currentBackStackEntryAsState
42import androidx.navigation.compose.rememberNavController
43import androidx.navigation.navArgument
44import androidx.lifecycle.compose.collectAsStateWithLifecycle
45import kotlinx.coroutines.launch
46import soy.iko.opencode.di.AppContainer
47import soy.iko.opencode.data.network.NetworkConfig
48import soy.iko.opencode.ui.chat.ChatScreen
49import soy.iko.opencode.ui.file.FileBrowserScreen
50import soy.iko.opencode.ui.file.FileViewScreen
51import soy.iko.opencode.ui.file.TwoPaneFileBrowser
52import soy.iko.opencode.ui.server.ServerEditScreen
53import soy.iko.opencode.ui.server.ServerListScreen
54import soy.iko.opencode.ui.search.GlobalSearchScreen
55import androidx.compose.ui.platform.LocalHapticFeedback
56import soy.iko.opencode.ui.components.LargeScreenNavRail
57import soy.iko.opencode.ui.components.CompactNavBar
58import soy.iko.opencode.ui.components.LocalChatTextScale
59import soy.iko.opencode.ui.components.LocalCodeWrap
60import soy.iko.opencode.ui.components.LocalReducedMotion
61import soy.iko.opencode.ui.components.isReducedMotion
62import soy.iko.opencode.ui.components.rememberGatedHaptics
63import soy.iko.opencode.ui.session.SessionListScreen
64import soy.iko.opencode.ui.session.TwoPaneSessionChat
65import soy.iko.opencode.ui.settings.DiagnosticsScreen
66import soy.iko.opencode.ui.settings.SettingsScreen
67import soy.iko.opencode.ui.usage.UsageScreen
68import soy.iko.opencode.ui.mcp.McpScreen
69import soy.iko.opencode.util.findActivity
70import soy.iko.opencode.util.runCatchingCancellable
73fun OpencodeApp(container: AppContainer) {
74 val navController = rememberNavController()
75 val scope = rememberCoroutineScope()
76 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
77 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
78 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
79 val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
80 val pendingOpenFile by container.pendingOpenFile.collectAsStateWithLifecycle()
81 val pendingDiagnostics by container.pendingDiagnostics.collectAsStateWithLifecycle()
82 val connection by container.activeConnection.collectAsStateWithLifecycle()
83 // Chat presentation preferences, provided to the whole nav graph so the markdown/code
84 // renderers honor the user's text-size and code-wrap choices everywhere.
85 val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
86 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
87 // Haptics gating: collected once at the root so the in-app toggle can disable all
88 // performHapticFeedback calls everywhere via a single CompositionLocalProvider, instead
89 // of each call site needing to read the setting. Defaults true (the prior behavior) so
90 // the brief window before DataStore loads still haptics as before.
91 val hapticsEnabled by container.settingsStore.hapticsEnabled.collectAsStateWithLifecycle(initialValue = true)
93 // The foreground service that holds priority while any run is active is driven from the
94 // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
95 // from here — a composition-scoped collector pauses while backgrounded, which is exactly
96 // when a run started via the notification reply needs the service to start.
98 // When text is shared into the app, surface the session list so the user can pick
99 // (or create) a conversation to drop it into. The chosen session's draft is set
100 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
101 // before auto-reconnect completes is retried once the connection is established.
102 // Track the last-handled share payloads (there's no share nonce on the container) so a
103 // *new* share arriving while already connected — a warm start where onNewIntent re-fires
104 // setPendingShare under an unchanged connection id — still navigates, while a re-trigger
105 // carrying no new share (a bare server switch, which routes through a null connection, or
106 // a share the user already navigated for) leaves them where they are.
108 // Reset the remembered media when the pending list is drained (the chosen ChatScreen
109 // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
110 // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
111 // "already handled", silently skipping the session-list navigation the second time.
113 // rememberSaveable (not remember) so a process-kill restore doesn't reset the dedup
114 // state and re-navigate to SESSIONS for a share the user already handled before the kill.
115 var handledShareText by rememberSaveable { mutableStateOf<String?>(null) }
116 var handledSharedMedia by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
117 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
118 // Reset the remembered handled state for each channel independently as it drains,
119 // so re-sharing the same payload after consumption is still recognized as new. A
120 // joint reset (only when both are empty) would drop a text-only re-share that
121 // follows a text+media share of the same text: the text channel matches its
122 // remembered value while the media channel is empty (not "new"), so neither clause
123 // fires and the share is silently swallowed.
124 if (pendingShare == null) handledShareText = null
125 if (pendingSharedMedia.isEmpty()) handledSharedMedia = emptyList()
126 if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
127 if (connection == null) return@LaunchedEffect
128 val newShare = (pendingShare != null && pendingShare != handledShareText) ||
129 (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
130 if (!newShare) return@LaunchedEffect
131 handledShareText = pendingShare
132 handledSharedMedia = pendingSharedMedia
133 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
134 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
138 // Crash-relaunch prompt: navigate to the Diagnostics screen when the user taps
139 // "View report" on the cold-start crash dialog. Doesn't require a connection.
140 LaunchedEffect(pendingDiagnostics) {
141 if (!pendingDiagnostics) return@LaunchedEffect
142 container.consumePendingDiagnostics()
143 navController.navigate(Routes.DIAGNOSTICS) { launchSingleTop = true }
146 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
147 // session and open it. The pending counter is consumed only *after* createSession
148 // succeeds, so a transient failure leaves the request pending to retry (on the next
149 // connection change or a re-tap that increments the counter) instead of silently
151 LaunchedEffect(pendingNewSession, connection) {
152 if (pendingNewSession == 0) return@LaunchedEffect
153 val conn = connection ?: return@LaunchedEffect
154 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
155 ?: return@LaunchedEffect
156 if (!container.consumePendingNewSession()) return@LaunchedEffect
157 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
158 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
160 navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
163 // The chat text-size preference multiplies on top of the OS font scale (sp already
164 // honors the system setting), so a large system size × large app size can reach ~2×
165 // and break tight layouts. Clamp the provided app scale so the product with the OS
166 // fontScale never exceeds NetworkConfig.maxCombinedFontScale, while never scaling
167 // below the user's chosen value's intent (floor of 1× app scale is preserved when the
168 // system size alone already exceeds the cap).
169 val systemFontScale = LocalDensity.current.fontScale
170 val clampedChatTextScale = if (systemFontScale <= 0f) {
173 chatTextScale.coerceAtMost(
174 (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
178 // Capture the reduced-motion preference once (in the composable scope) so the
179 // non-composable NavHost transition lambdas can use it to skip animations, and provide
180 // it via LocalReducedMotion so in-composition animations (chat row placement, banners,
181 // image zoom) honor the same accessibility preference.
182 val reducedMotion = isReducedMotion()
183 CompositionLocalProvider(
184 LocalChatTextScale provides clampedChatTextScale,
185 LocalCodeWrap provides codeWrap,
186 LocalReducedMotion provides reducedMotion,
187 LocalHapticFeedback provides rememberGatedHaptics(hapticsEnabled),
189 // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
190 // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
191 val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
192 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
193 // the open conversation side by side instead of a single-pane back stack.
194 // Posture-aware: a foldable in HALF_OPENED (tabletop/book) mode has a hinge across the
195 // content area — splitting there would place the list/detail boundary on the hinge, which
196 // is hard to read and interact with. Detect a FoldingFeature and suppress two-pane in
197 // those postures so the app falls back to a single-pane layout that respects the fold.
198 val windowContext = LocalContext.current
199 var foldState by remember { mutableStateOf<androidx.window.layout.FoldingFeature?>(null) }
200 androidx.compose.runtime.LaunchedEffect(windowContext) {
201 val activity = windowContext.findActivity()
202 if (activity != null) {
203 androidx.window.layout.WindowInfoTracker.getOrCreate(windowContext)
204 .windowLayoutInfo(activity)
206 foldState = info.displayFeatures
207 .filterIsInstance<androidx.window.layout.FoldingFeature>()
212 val isHalfOpened = foldState?.state == androidx.window.layout.FoldingFeature.State.HALF_OPENED
214 modifier = Modifier.onPreviewKeyEvent { ev ->
215 // Global keyboard shortcuts for hardware-keyboard users (tablets, DeX, Chromebook):
216 // Ctrl+1..6 switches between top-level destinations. Only fires on KeyDown so a
217 // held Ctrl doesn't re-trigger on every repeat. Per-screen shortcuts (Ctrl+K palette,
218 // Esc stop, Ctrl+F find) are handled in each screen's own onPreviewKeyEvent and take
219 // precedence (they're closer to the focused composable in the tree).
220 if (ev.type != KeyEventType.KeyDown || !ev.isCtrlPressed) {
221 // Alt+Left (a familiar desktop "Back" gesture) pops the back stack. Not gated
222 // on Ctrl so it works as a standalone modifier. Per-screen shortcuts (Esc
223 // stop, Ctrl+F find, Ctrl+K palette) register their own onPreviewKeyEvent
224 // closer to the focused composable and consume first, so this only pops when
225 // nothing nearer handled the key — avoiding conflicts with open dialogs and
226 // text fields. Esc is deliberately NOT mapped to back here for that reason.
227 if (ev.type == KeyEventType.KeyDown &&
228 ev.key == Key.DirectionLeft &&
231 if (navController.previousBackStackEntry != null) {
232 navController.popBackStack()
233 return@onPreviewKeyEvent true
236 return@onPreviewKeyEvent false
238 val route = when (ev.key) {
239 Key.NumPad1, Key.One -> Routes.SESSIONS
240 Key.NumPad2, Key.Two -> Routes.SEARCH
241 Key.NumPad3, Key.Three -> Routes.FILES
242 Key.NumPad4, Key.Four -> Routes.USAGE
243 Key.NumPad5, Key.Five -> Routes.MCP
244 Key.NumPad6, Key.Six -> Routes.SETTINGS
245 // Ctrl+7 navigates to the server list (the 7th nav-rail destination).
246 // Previously Ctrl+1..6 covered the other destinations but Servers was
247 // missing, an inconsistency since the nav rail shows 7 destinations.
248 Key.NumPad7, Key.Seven -> Routes.SERVERS
252 // Servers is reachable even without an active connection (it's the start
253 // destination and handles the not-connected state); the other routes
254 // require a connection to be meaningful.
255 if (route == Routes.SERVERS || connection != null) {
256 navController.navigate(route) {
257 popUpTo(navController.graph.findStartDestination().id) { saveState = true }
258 launchSingleTop = true
266 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp &&
267 connection != null &&
269 // On large screens, surface a navigation rail alongside the content so top-level
270 // destinations (Sessions, Files, Search, …) are one tap away instead of buried in a
271 // screen's overflow menu. Mirrors the standard large-screen M3 layout.
272 val showNavRail = maxWidth >= NetworkConfig.navigationRailThresholdDp.dp
273 // Observe the current destination reactively so the rail's selected item tracks navigation
274 // (reading currentDestination once would go stale after the first composition).
275 val currentEntry by navController.currentBackStackEntryAsState()
276 val currentRoute = currentEntry?.destination?.route
278 // Open a session requested by a notification tap or deep link, once connected.
279 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
280 val pending = pendingOpenSession ?: return@LaunchedEffect
281 if (connection == null) return@LaunchedEffect
282 // If the notification body-tap carries a profile id from a different server,
283 // switch to it first so the session opens under the server that ran it, not
284 // whichever is active. The effect re-runs when connection changes (keyed on it),
285 // so on the second run the profile matches and the open proceeds.
286 if (pending.profileId != null && connection?.profile?.id != pending.profileId) {
287 if (!container.connectByProfileId(pending.profileId)) return@LaunchedEffect
288 return@LaunchedEffect // re-run on the new connection
290 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
291 // route) consumes the request and opens it in the detail pane. But it only
292 // exists while the NavHost is on SESSIONS, so if the user is currently on
293 // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
294 // pending request is never consumed and the session never opens. Don't consume
295 // here; let TwoPaneSessionChat's effect do it once it's composed.
297 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
298 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
300 return@LaunchedEffect
302 container.consumePendingOpenSession()
303 // Ensure SESSIONS sits on the back stack below the chat destination so backing
304 // out of the chat lands on the session list (not the server list). Bring SESSIONS
305 // to front (or push it if a cold-start deep link left only SERVERS on the stack),
306 // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
307 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
308 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
310 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
313 // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
314 // connection exists (the viewer fetches content from the server). Path comes pre-validated
315 // by MainActivity's deep-link guard. In two-pane mode the detail pane (TwoPaneFileBrowser,
316 // hosted on the FILES route) consumes the request into its detail pane, mirroring how
317 // session deep links are routed — so don't consume or push the full-screen viewer here.
318 LaunchedEffect(pendingOpenFile, connection, isTwoPane) {
319 val path = pendingOpenFile ?: return@LaunchedEffect
320 if (connection == null) return@LaunchedEffect
322 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
323 navController.navigate(Routes.FILES) { launchSingleTop = true }
325 return@LaunchedEffect
327 container.consumePendingOpenFile()
328 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
329 navController.navigate(Routes.FILES) { launchSingleTop = true }
331 navController.navigate(Routes.fileView(path)) { launchSingleTop = true }
334 val navHost = @Composable {
336 modifier = Modifier.fillMaxSize(),
337 navController = navController,
338 startDestination = Routes.SERVERS,
339 // Motion-aware transitions: respect the user's reduced-motion setting
340 // (Developer Options → animator scale 0) by skipping animations entirely.
341 // Per-destination tuning: a chat→chat navigation (switching peer conversations
342 // within the same surface) crossfades instead of sliding, so it reads as a peer
343 // switch rather than drilling into a new section. Top-level destinations and
344 // detail pushes keep the horizontal slide.
348 } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
349 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
351 slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
352 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
359 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
362 popEnterTransition = {
366 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
369 popExitTransition = {
373 slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
374 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
379 composable(Routes.SERVERS) {
381 container = container,
383 // Only leave the server list if we're still on it. A pending deep-link /
384 // notification open sets activeConnection (which drives the open-session
385 // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
386 // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
387 // on top of the chat it just opened, burying it. Order-independent: if the
388 // deep link already navigated away from SERVERS, this is a no-op.
389 if (navController.currentDestination?.route == Routes.SERVERS) {
390 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
393 onAddProfile = { navController.navigate(Routes.serverEdit()) },
394 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
395 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
400 route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
402 navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
403 navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
407 container = container,
408 profileId = entry.arguments?.getString("id"),
409 sourceId = entry.arguments?.getString("dup"),
410 onDone = { navController.popBackStack() },
414 composable(Routes.SESSIONS) {
417 container = container,
418 onOpenFiles = { navController.navigate(Routes.FILES) },
419 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
420 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
421 onOpenSearch = { navController.navigate(Routes.SEARCH) },
423 scope.launch { runCatchingCancellable { container.disconnect() } }
424 navController.popBackStack(Routes.SERVERS, inclusive = false)
426 onAddServer = { navController.navigate(Routes.serverEdit()) },
427 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
431 container = container,
432 onOpenSession = { id ->
433 // Set the draft synchronously in-memory before navigating so the
434 // ChatScreen sees it on first composition. The async persistence
435 // to disk happens in the background via draftStore.set.
436 container.consumePendingShare()?.let { shareText ->
437 container.draftStore.setImmediate(id, shareText)
438 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
440 navController.navigate(Routes.chat(id))
443 scope.launch { runCatchingCancellable { container.disconnect() } }
444 navController.popBackStack(Routes.SERVERS, inclusive = false)
446 onOpenFiles = { navController.navigate(Routes.FILES) },
447 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
448 onOpenSearch = { navController.navigate(Routes.SEARCH) },
449 onAddServer = { navController.navigate(Routes.serverEdit()) },
450 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
456 route = "${Routes.CHAT}/{sessionId}?focus={focus}",
458 navArgument("sessionId") { type = NavType.StringType },
459 navArgument("focus") { type = NavType.StringType; nullable = true; defaultValue = null },
463 container = container,
464 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
465 focusMessageId = entry.arguments?.getString("focus"),
466 onBack = { navController.popBackStack() },
467 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
468 onOpenSession = { id ->
469 navController.navigate(Routes.chat(id)) {
470 // Prevent the same chat from being pushed twice when re-tapping a
471 // session deep link, and keep the back stack bounded when chaining
472 // chat → linked chat → … (otherwise every hop piles a new entry).
473 launchSingleTop = true
476 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
480 composable(Routes.FILES) {
483 container = container,
484 onBack = { navController.popBackStack() },
488 container = container,
489 onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
490 onBack = { navController.popBackStack() },
496 route = "${Routes.FILE_VIEW}?path={path}&line={line}",
498 navArgument("path") { type = NavType.StringType; defaultValue = "" },
499 navArgument("line") { type = NavType.IntType; defaultValue = -1 },
502 val line = entry.arguments?.getInt("line") ?: -1
504 container = container,
505 path = entry.arguments?.getString("path").orEmpty(),
506 initialLine = line.takeIf { it > 0 },
507 onBack = { navController.popBackStack() },
511 composable(Routes.SETTINGS) {
513 container = container,
514 onBack = { navController.popBackStack() },
515 // Navigate to the server list as a new entry on top of Sessions so the
516 // user can back out to their conversations. Previously this popped back
517 // to the root SERVERS destination, which removed Sessions from the stack
518 // and left the user unable to return to their conversations without
519 // reconnecting. Pop only Settings, then push SERVERS; back from the
520 // server list lands on Sessions (the screen the user was on before
521 // Settings), and connecting pushes a fresh SESSIONS as usual.
523 navController.navigate(Routes.SERVERS) {
524 popUpTo(Routes.SETTINGS) { inclusive = true }
525 launchSingleTop = true
528 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
529 onOpenUsage = { navController.navigate(Routes.USAGE) },
530 onOpenMcp = { navController.navigate(Routes.MCP) },
531 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
535 composable(Routes.DIAGNOSTICS) {
536 DiagnosticsScreen(onBack = { navController.popBackStack() })
539 composable(Routes.USAGE) {
541 container = container,
542 onBack = { navController.popBackStack() },
543 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
547 composable(Routes.MCP) {
548 McpScreen(container = container, onBack = { navController.popBackStack() })
551 composable(Routes.SEARCH) {
553 container = container,
554 onOpenSession = { id, focusMessageId -> navController.navigate(Routes.chat(id, focusMessageId)) },
555 onBack = { navController.popBackStack() },
561 // Collect unread/run state for nav badges (rail on large screens, bottom bar on
562 // compact screens), so a user on Files/Settings sees pending activity at a glance.
563 val unread by container.unread.collectAsStateWithLifecycle()
564 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
565 val totalUnread = remember(unread) { unread.values.sum() }
568 Row(modifier = Modifier.fillMaxSize()) {
570 currentRoute = currentRoute,
571 connected = connection != null,
572 unreadCount = totalUnread,
573 runActive = anyRunActive,
574 // Use the standard M3 nav-bar pattern: popUpTo the start destination
575 // saving state, and restoreState on re-entry, so switching between
576 // top-level tabs via the rail preserves each tab's scroll/selection
577 // state and back always exits cleanly instead of popping through
578 // every sibling tab the user visited.
579 onNavigate = { route ->
580 navController.navigate(route) {
581 popUpTo(navController.graph.findStartDestination().id) {
584 launchSingleTop = true
589 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
592 // Compact screens: a bottom NavigationBar surfaces the top-level destinations one
593 // tap away (Sessions/Search/Files/Settings/Servers) instead of buried in each
594 // screen's overflow menu. The navHost fills the space above the bar; the bar's
595 // own height is accounted for by the NavigationBar's intrinsic measurement.
596 androidx.compose.foundation.layout.Column(modifier = Modifier.fillMaxSize()) {
597 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
599 currentRoute = currentRoute,
600 connected = connection != null,
601 unreadCount = totalUnread,
602 runActive = anyRunActive,
603 onNavigate = { route ->
604 navController.navigate(route) {
605 popUpTo(navController.graph.findStartDestination().id) {
608 launchSingleTop = true
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