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