git.iko.soy/opencode-android
on 61cdc75 help sign in
553 lines 30 kB history raw
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.key
29import androidx.compose.ui.input.key.onPreviewKeyEvent
30import androidx.compose.ui.input.key.type
31import androidx.compose.ui.platform.LocalContext
32import androidx.compose.ui.platform.LocalDensity
33import androidx.compose.ui.unit.dp
34import androidx.compose.ui.unit.LayoutDirection
35import androidx.compose.ui.platform.LocalLayoutDirection
36import androidx.navigation.NavType
37import androidx.navigation.NavGraph.Companion.findStartDestination
38import androidx.navigation.compose.NavHost
39import androidx.navigation.compose.composable
40import androidx.navigation.compose.currentBackStackEntryAsState
41import androidx.navigation.compose.rememberNavController
42import androidx.navigation.navArgument
43import androidx.lifecycle.compose.collectAsStateWithLifecycle
44import kotlinx.coroutines.launch
45import soy.iko.opencode.di.AppContainer
46import soy.iko.opencode.data.network.NetworkConfig
47import soy.iko.opencode.ui.chat.ChatScreen
48import soy.iko.opencode.ui.file.FileBrowserScreen
49import soy.iko.opencode.ui.file.FileViewScreen
50import soy.iko.opencode.ui.file.TwoPaneFileBrowser
51import soy.iko.opencode.ui.server.ServerEditScreen
52import soy.iko.opencode.ui.server.ServerListScreen
53import soy.iko.opencode.ui.search.GlobalSearchScreen
54import soy.iko.opencode.ui.components.LargeScreenNavRail
55import soy.iko.opencode.ui.components.LocalChatTextScale
56import soy.iko.opencode.ui.components.LocalCodeWrap
57import soy.iko.opencode.ui.components.LocalReducedMotion
58import soy.iko.opencode.ui.components.isReducedMotion
59import soy.iko.opencode.ui.session.SessionListScreen
60import soy.iko.opencode.ui.session.TwoPaneSessionChat
61import soy.iko.opencode.ui.settings.DiagnosticsScreen
62import soy.iko.opencode.ui.settings.SettingsScreen
63import soy.iko.opencode.ui.usage.UsageScreen
64import soy.iko.opencode.ui.mcp.McpScreen
65import soy.iko.opencode.util.findActivity
66import soy.iko.opencode.util.runCatchingCancellable
68@Composable
69fun OpencodeApp(container: AppContainer) {
70 val navController = rememberNavController()
71 val scope = rememberCoroutineScope()
72 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
73 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
74 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
75 val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
76 val pendingOpenFile by container.pendingOpenFile.collectAsStateWithLifecycle()
77 val pendingDiagnostics by container.pendingDiagnostics.collectAsStateWithLifecycle()
78 val connection by container.activeConnection.collectAsStateWithLifecycle()
79 // Chat presentation preferences, provided to the whole nav graph so the markdown/code
80 // renderers honor the user's text-size and code-wrap choices everywhere.
81 val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
82 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
84 // The foreground service that holds priority while any run is active is driven from the
85 // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
86 // from here — a composition-scoped collector pauses while backgrounded, which is exactly
87 // when a run started via the notification reply needs the service to start.
89 // When text is shared into the app, surface the session list so the user can pick
90 // (or create) a conversation to drop it into. The chosen session's draft is set
91 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
92 // before auto-reconnect completes is retried once the connection is established.
93 // Track the last-handled share payloads (there's no share nonce on the container) so a
94 // *new* share arriving while already connected — a warm start where onNewIntent re-fires
95 // setPendingShare under an unchanged connection id — still navigates, while a re-trigger
96 // carrying no new share (a bare server switch, which routes through a null connection, or
97 // a share the user already navigated for) leaves them where they are.
98 //
99 // Reset the remembered media when the pending list is drained (the chosen ChatScreen
100 // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
101 // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
102 // "already handled", silently skipping the session-list navigation the second time.
103 //
104 // rememberSaveable (not remember) so a process-kill restore doesn't reset the dedup
105 // state and re-navigate to SESSIONS for a share the user already handled before the kill.
106 var handledShareText by rememberSaveable { mutableStateOf<String?>(null) }
107 var handledSharedMedia by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
108 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
109 // Reset the remembered handled state for each channel independently as it drains,
110 // so re-sharing the same payload after consumption is still recognized as new. A
111 // joint reset (only when both are empty) would drop a text-only re-share that
112 // follows a text+media share of the same text: the text channel matches its
113 // remembered value while the media channel is empty (not "new"), so neither clause
114 // fires and the share is silently swallowed.
115 if (pendingShare == null) handledShareText = null
116 if (pendingSharedMedia.isEmpty()) handledSharedMedia = emptyList()
117 if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
118 if (connection == null) return@LaunchedEffect
119 val newShare = (pendingShare != null && pendingShare != handledShareText) ||
120 (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
121 if (!newShare) return@LaunchedEffect
122 handledShareText = pendingShare
123 handledSharedMedia = pendingSharedMedia
124 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
125 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
126 }
127 }
129 // Crash-relaunch prompt: navigate to the Diagnostics screen when the user taps
130 // "View report" on the cold-start crash dialog. Doesn't require a connection.
131 LaunchedEffect(pendingDiagnostics) {
132 if (!pendingDiagnostics) return@LaunchedEffect
133 container.consumePendingDiagnostics()
134 navController.navigate(Routes.DIAGNOSTICS) { launchSingleTop = true }
135 }
137 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
138 // session and open it. The pending counter is consumed only *after* createSession
139 // succeeds, so a transient failure leaves the request pending to retry (on the next
140 // connection change or a re-tap that increments the counter) instead of silently
141 // dropping the tap.
142 LaunchedEffect(pendingNewSession, connection) {
143 if (pendingNewSession == 0) return@LaunchedEffect
144 val conn = connection ?: return@LaunchedEffect
145 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
146 ?: return@LaunchedEffect
147 if (!container.consumePendingNewSession()) return@LaunchedEffect
148 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
149 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
150 }
151 navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
152 }
154 // The chat text-size preference multiplies on top of the OS font scale (sp already
155 // honors the system setting), so a large system size × large app size can reach ~2×
156 // and break tight layouts. Clamp the provided app scale so the product with the OS
157 // fontScale never exceeds NetworkConfig.maxCombinedFontScale, while never scaling
158 // below the user's chosen value's intent (floor of 1× app scale is preserved when the
159 // system size alone already exceeds the cap).
160 val systemFontScale = LocalDensity.current.fontScale
161 val clampedChatTextScale = if (systemFontScale <= 0f) {
162 chatTextScale
163 } else {
164 chatTextScale.coerceAtMost(
165 (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
166 )
167 }
169 // Capture the reduced-motion preference once (in the composable scope) so the
170 // non-composable NavHost transition lambdas can use it to skip animations, and provide
171 // it via LocalReducedMotion so in-composition animations (chat row placement, banners,
172 // image zoom) honor the same accessibility preference.
173 val reducedMotion = isReducedMotion()
174 CompositionLocalProvider(
175 LocalChatTextScale provides clampedChatTextScale,
176 LocalCodeWrap provides codeWrap,
177 LocalReducedMotion provides reducedMotion,
178 ) {
179 // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
180 // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
181 val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
182 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
183 // the open conversation side by side instead of a single-pane back stack.
184 // Posture-aware: a foldable in HALF_OPENED (tabletop/book) mode has a hinge across the
185 // content area — splitting there would place the list/detail boundary on the hinge, which
186 // is hard to read and interact with. Detect a FoldingFeature and suppress two-pane in
187 // those postures so the app falls back to a single-pane layout that respects the fold.
188 val windowContext = LocalContext.current
189 var foldState by remember { mutableStateOf<androidx.window.layout.FoldingFeature?>(null) }
190 androidx.compose.runtime.LaunchedEffect(windowContext) {
191 val activity = windowContext.findActivity()
192 if (activity != null) {
193 androidx.window.layout.WindowInfoTracker.getOrCreate(windowContext)
194 .windowLayoutInfo(activity)
195 .collect { info ->
196 foldState = info.displayFeatures
197 .filterIsInstance<androidx.window.layout.FoldingFeature>()
198 .firstOrNull()
199 }
200 }
201 }
202 val isHalfOpened = foldState?.state == androidx.window.layout.FoldingFeature.State.HALF_OPENED
203 BoxWithConstraints(
204 modifier = Modifier.onPreviewKeyEvent { ev ->
205 // Global keyboard shortcuts for hardware-keyboard users (tablets, DeX, Chromebook):
206 // Ctrl+1..6 switches between top-level destinations. Only fires on KeyDown so a
207 // held Ctrl doesn't re-trigger on every repeat. Per-screen shortcuts (Ctrl+K palette,
208 // Esc stop, Ctrl+F find) are handled in each screen's own onPreviewKeyEvent and take
209 // precedence (they're closer to the focused composable in the tree).
210 if (ev.type != KeyEventType.KeyDown || !ev.isCtrlPressed) {
211 return@onPreviewKeyEvent false
212 }
213 val route = when (ev.key) {
214 Key.NumPad1, Key.One -> Routes.SESSIONS
215 Key.NumPad2, Key.Two -> Routes.SEARCH
216 Key.NumPad3, Key.Three -> Routes.FILES
217 Key.NumPad4, Key.Four -> Routes.USAGE
218 Key.NumPad5, Key.Five -> Routes.MCP
219 Key.NumPad6, Key.Six -> Routes.SETTINGS
220 else -> null
221 }
222 if (route != null && connection != null) {
223 navController.navigate(route) {
224 popUpTo(navController.graph.findStartDestination().id) { saveState = true }
225 launchSingleTop = true
226 restoreState = true
227 }
228 true
229 } else false
230 },
231 ) {
232 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp &&
233 connection != null &&
234 !isHalfOpened
235 // On large screens, surface a navigation rail alongside the content so top-level
236 // destinations (Sessions, Files, Search, …) are one tap away instead of buried in a
237 // screen's overflow menu. Mirrors the standard large-screen M3 layout.
238 val showNavRail = maxWidth >= NetworkConfig.navigationRailThresholdDp.dp
239 // Observe the current destination reactively so the rail's selected item tracks navigation
240 // (reading currentDestination once would go stale after the first composition).
241 val currentEntry by navController.currentBackStackEntryAsState()
242 val currentRoute = currentEntry?.destination?.route
244 // Open a session requested by a notification tap or deep link, once connected.
245 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
246 val pending = pendingOpenSession ?: return@LaunchedEffect
247 if (connection == null) return@LaunchedEffect
248 // If the notification body-tap carries a profile id from a different server,
249 // switch to it first so the session opens under the server that ran it, not
250 // whichever is active. The effect re-runs when connection changes (keyed on it),
251 // so on the second run the profile matches and the open proceeds.
252 if (pending.profileId != null && connection?.profile?.id != pending.profileId) {
253 if (!container.connectByProfileId(pending.profileId)) return@LaunchedEffect
254 return@LaunchedEffect // re-run on the new connection
255 }
256 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
257 // route) consumes the request and opens it in the detail pane. But it only
258 // exists while the NavHost is on SESSIONS, so if the user is currently on
259 // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
260 // pending request is never consumed and the session never opens. Don't consume
261 // here; let TwoPaneSessionChat's effect do it once it's composed.
262 if (isTwoPane) {
263 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
264 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
265 }
266 return@LaunchedEffect
267 }
268 container.consumePendingOpenSession()
269 // Ensure SESSIONS sits on the back stack below the chat destination so backing
270 // out of the chat lands on the session list (not the server list). Bring SESSIONS
271 // to front (or push it if a cold-start deep link left only SERVERS on the stack),
272 // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
273 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
274 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
275 }
276 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
277 }
279 // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
280 // connection exists (the viewer fetches content from the server). Path comes pre-validated
281 // by MainActivity's deep-link guard. In two-pane mode the detail pane (TwoPaneFileBrowser,
282 // hosted on the FILES route) consumes the request into its detail pane, mirroring how
283 // session deep links are routed — so don't consume or push the full-screen viewer here.
284 LaunchedEffect(pendingOpenFile, connection, isTwoPane) {
285 val path = pendingOpenFile ?: return@LaunchedEffect
286 if (connection == null) return@LaunchedEffect
287 if (isTwoPane) {
288 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
289 navController.navigate(Routes.FILES) { launchSingleTop = true }
290 }
291 return@LaunchedEffect
292 }
293 container.consumePendingOpenFile()
294 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
295 navController.navigate(Routes.FILES) { launchSingleTop = true }
296 }
297 navController.navigate(Routes.fileView(path)) { launchSingleTop = true }
298 }
300 val navHost = @Composable {
301 NavHost(
302 modifier = Modifier.fillMaxSize(),
303 navController = navController,
304 startDestination = Routes.SERVERS,
305 // Motion-aware transitions: respect the user's reduced-motion setting
306 // (Developer Options → animator scale 0) by skipping animations entirely.
307 // Per-destination tuning: a chat→chat navigation (switching peer conversations
308 // within the same surface) crossfades instead of sliding, so it reads as a peer
309 // switch rather than drilling into a new section. Top-level destinations and
310 // detail pushes keep the horizontal slide.
311 enterTransition = {
312 if (reducedMotion) {
313 EnterTransition.None
314 } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
315 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
316 } else {
317 slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
318 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
319 }
320 },
321 exitTransition = {
322 if (reducedMotion) {
323 ExitTransition.None
324 } else {
325 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
326 }
327 },
328 popEnterTransition = {
329 if (reducedMotion) {
330 EnterTransition.None
331 } else {
332 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
333 }
334 },
335 popExitTransition = {
336 if (reducedMotion) {
337 ExitTransition.None
338 } else {
339 slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
340 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
341 }
342 },
343 ) {
345 composable(Routes.SERVERS) {
346 ServerListScreen(
347 container = container,
348 onConnected = {
349 // Only leave the server list if we're still on it. A pending deep-link /
350 // notification open sets activeConnection (which drives the open-session
351 // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
352 // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
353 // on top of the chat it just opened, burying it. Order-independent: if the
354 // deep link already navigated away from SERVERS, this is a no-op.
355 if (navController.currentDestination?.route == Routes.SERVERS) {
356 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
357 }
358 },
359 onAddProfile = { navController.navigate(Routes.serverEdit()) },
360 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
361 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
362 )
363 }
365 composable(
366 route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
367 arguments = listOf(
368 navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
369 navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
370 ),
371 ) { entry ->
372 ServerEditScreen(
373 container = container,
374 profileId = entry.arguments?.getString("id"),
375 sourceId = entry.arguments?.getString("dup"),
376 onDone = { navController.popBackStack() },
377 )
378 }
380 composable(Routes.SESSIONS) {
381 if (isTwoPane) {
382 TwoPaneSessionChat(
383 container = container,
384 onOpenFiles = { navController.navigate(Routes.FILES) },
385 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
386 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
387 onOpenSearch = { navController.navigate(Routes.SEARCH) },
388 onDisconnect = {
389 scope.launch { runCatchingCancellable { container.disconnect() } }
390 navController.popBackStack(Routes.SERVERS, inclusive = false)
391 },
392 onAddServer = { navController.navigate(Routes.serverEdit()) },
393 )
394 } else {
395 SessionListScreen(
396 container = container,
397 onOpenSession = { id ->
398 // Set the draft synchronously in-memory before navigating so the
399 // ChatScreen sees it on first composition. The async persistence
400 // to disk happens in the background via draftStore.set.
401 container.consumePendingShare()?.let { shareText ->
402 container.draftStore.setImmediate(id, shareText)
403 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
404 }
405 navController.navigate(Routes.chat(id))
406 },
407 onDisconnect = {
408 scope.launch { runCatchingCancellable { container.disconnect() } }
409 navController.popBackStack(Routes.SERVERS, inclusive = false)
410 },
411 onOpenFiles = { navController.navigate(Routes.FILES) },
412 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
413 onOpenSearch = { navController.navigate(Routes.SEARCH) },
414 onAddServer = { navController.navigate(Routes.serverEdit()) },
415 )
416 }
417 }
419 composable(
420 route = "${Routes.CHAT}/{sessionId}",
421 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
422 ) { entry ->
423 ChatScreen(
424 container = container,
425 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
426 onBack = { navController.popBackStack() },
427 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
428 onOpenSession = { id ->
429 navController.navigate(Routes.chat(id)) {
430 // Prevent the same chat from being pushed twice when re-tapping a
431 // session deep link, and keep the back stack bounded when chaining
432 // chat → linked chat → … (otherwise every hop piles a new entry).
433 launchSingleTop = true
434 }
435 },
436 )
437 }
439 composable(Routes.FILES) {
440 if (isTwoPane) {
441 TwoPaneFileBrowser(
442 container = container,
443 onBack = { navController.popBackStack() },
444 )
445 } else {
446 FileBrowserScreen(
447 container = container,
448 onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
449 onBack = { navController.popBackStack() },
450 )
451 }
452 }
454 composable(
455 route = "${Routes.FILE_VIEW}?path={path}&line={line}",
456 arguments = listOf(
457 navArgument("path") { type = NavType.StringType; defaultValue = "" },
458 navArgument("line") { type = NavType.IntType; defaultValue = -1 },
459 ),
460 ) { entry ->
461 val line = entry.arguments?.getInt("line") ?: -1
462 FileViewScreen(
463 container = container,
464 path = entry.arguments?.getString("path").orEmpty(),
465 initialLine = line.takeIf { it > 0 },
466 onBack = { navController.popBackStack() },
467 )
468 }
470 composable(Routes.SETTINGS) {
471 SettingsScreen(
472 container = container,
473 onBack = { navController.popBackStack() },
474 // Navigate to the server list as a new entry on top of Sessions so the
475 // user can back out to their conversations. Previously this popped back
476 // to the root SERVERS destination, which removed Sessions from the stack
477 // and left the user unable to return to their conversations without
478 // reconnecting. Pop only Settings, then push SERVERS; back from the
479 // server list lands on Sessions (the screen the user was on before
480 // Settings), and connecting pushes a fresh SESSIONS as usual.
481 onManageServers = {
482 navController.navigate(Routes.SERVERS) {
483 popUpTo(Routes.SETTINGS) { inclusive = true }
484 launchSingleTop = true
485 }
486 },
487 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
488 onOpenUsage = { navController.navigate(Routes.USAGE) },
489 onOpenMcp = { navController.navigate(Routes.MCP) },
490 )
491 }
493 composable(Routes.DIAGNOSTICS) {
494 DiagnosticsScreen(onBack = { navController.popBackStack() })
495 }
497 composable(Routes.USAGE) {
498 UsageScreen(
499 container = container,
500 onBack = { navController.popBackStack() },
501 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
502 )
503 }
505 composable(Routes.MCP) {
506 McpScreen(container = container, onBack = { navController.popBackStack() })
507 }
509 composable(Routes.SEARCH) {
510 GlobalSearchScreen(
511 container = container,
512 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
513 onBack = { navController.popBackStack() },
514 )
515 }
516 }
517 }
519 if (showNavRail) {
520 // Collect unread/run state for the nav rail's Sessions badge, so a user on
521 // Files/Settings sees pending activity at a glance.
522 val unread by container.unread.collectAsStateWithLifecycle()
523 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
524 val totalUnread = remember(unread) { unread.values.sum() }
525 Row(modifier = Modifier.fillMaxSize()) {
526 LargeScreenNavRail(
527 currentRoute = currentRoute,
528 connected = connection != null,
529 unreadCount = totalUnread,
530 runActive = anyRunActive,
531 // Use the standard M3 nav-bar pattern: popUpTo the start destination
532 // saving state, and restoreState on re-entry, so switching between
533 // top-level tabs via the rail preserves each tab's scroll/selection
534 // state and back always exits cleanly instead of popping through
535 // every sibling tab the user visited.
536 onNavigate = { route ->
537 navController.navigate(route) {
538 popUpTo(navController.graph.findStartDestination().id) {
539 saveState = true
540 }
541 launchSingleTop = true
542 restoreState = true
543 }
544 },
545 )
546 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
547 }
548 } else {
549 navHost()
550 }
551 }
552 }