git.iko.soy/opencode-android
on e66c7ed help sign in
630 lines 35 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 the originating profile no longer exists (e.g. deleted after the
289 // notification was queued), connectByProfileId returns false: the active
290 // connection is unchanged and none of this effect's keys change, so it
291 // would never re-run — the request would sit in _pendingOpenSession
292 // forever and re-trigger on every later connection change. Consume it so
293 // the stale request is dropped, mirroring the invalid-deep-link path in
294 // MainActivity.
295 if (!container.connectByProfileId(pending.profileId)) {
296 container.consumePendingOpenSession()
297 return@LaunchedEffect
298 }
299 return@LaunchedEffect // re-run on the new connection
300 }
301 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
302 // route) consumes the request and opens it in the detail pane. But it only
303 // exists while the NavHost is on SESSIONS, so if the user is currently on
304 // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
305 // pending request is never consumed and the session never opens. Don't consume
306 // here; let TwoPaneSessionChat's effect do it once it's composed.
307 if (isTwoPane) {
308 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
309 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
310 }
311 return@LaunchedEffect
312 }
313 container.consumePendingOpenSession()
314 // Ensure SESSIONS sits on the back stack below the chat destination so backing
315 // out of the chat lands on the session list (not the server list). Bring SESSIONS
316 // to front (or push it if a cold-start deep link left only SERVERS on the stack),
317 // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
318 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
319 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
320 }
321 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
322 }
324 // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
325 // connection exists (the viewer fetches content from the server). Path comes pre-validated
326 // by MainActivity's deep-link guard. In two-pane mode the detail pane (TwoPaneFileBrowser,
327 // hosted on the FILES route) consumes the request into its detail pane, mirroring how
328 // session deep links are routed — so don't consume or push the full-screen viewer here.
329 LaunchedEffect(pendingOpenFile, connection, isTwoPane) {
330 val path = pendingOpenFile ?: return@LaunchedEffect
331 if (connection == null) return@LaunchedEffect
332 if (isTwoPane) {
333 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
334 navController.navigate(Routes.FILES) { launchSingleTop = true }
335 }
336 return@LaunchedEffect
337 }
338 container.consumePendingOpenFile()
339 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
340 navController.navigate(Routes.FILES) { launchSingleTop = true }
341 }
342 navController.navigate(Routes.fileView(path)) { launchSingleTop = true }
343 }
345 val navHost = @Composable {
346 NavHost(
347 modifier = Modifier.fillMaxSize(),
348 navController = navController,
349 startDestination = Routes.SERVERS,
350 // Motion-aware transitions: respect the user's reduced-motion setting
351 // (Developer Options → animator scale 0) by skipping animations entirely.
352 // Per-destination tuning: a chat→chat navigation (switching peer conversations
353 // within the same surface) crossfades instead of sliding, so it reads as a peer
354 // switch rather than drilling into a new section. Top-level destinations and
355 // detail pushes keep the horizontal slide.
356 enterTransition = {
357 if (reducedMotion) {
358 EnterTransition.None
359 } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
360 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
361 } else {
362 slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
363 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
364 }
365 },
366 exitTransition = {
367 if (reducedMotion) {
368 ExitTransition.None
369 } else {
370 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
371 }
372 },
373 popEnterTransition = {
374 if (reducedMotion) {
375 EnterTransition.None
376 } else {
377 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
378 }
379 },
380 popExitTransition = {
381 if (reducedMotion) {
382 ExitTransition.None
383 } else {
384 slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
385 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
386 }
387 },
388 ) {
390 composable(Routes.SERVERS) {
391 ServerListScreen(
392 container = container,
393 onConnected = {
394 // Only leave the server list if we're still on it. A pending deep-link /
395 // notification open sets activeConnection (which drives the open-session
396 // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
397 // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
398 // on top of the chat it just opened, burying it. Order-independent: if the
399 // deep link already navigated away from SERVERS, this is a no-op.
400 if (navController.currentDestination?.route == Routes.SERVERS) {
401 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
402 }
403 },
404 onAddProfile = { navController.navigate(Routes.SERVER_EDIT) },
405 onServerSettings = { id -> navController.navigate(Routes.serverSettings(id)) },
406 )
407 }
409 composable(Routes.SERVER_EDIT) {
410 ServerEditScreen(
411 container = container,
412 onDone = { navController.popBackStack() },
413 )
414 }
416 composable(
417 route = "${Routes.SERVER_SETTINGS}/{id}",
418 arguments = listOf(navArgument("id") { type = NavType.StringType }),
419 ) { entry ->
420 ServerSettingsScreen(
421 container = container,
422 profileId = entry.arguments?.getString("id").orEmpty(),
423 onDone = { navController.popBackStack() },
424 )
425 }
427 composable(Routes.SESSIONS) {
428 if (isTwoPane) {
429 TwoPaneSessionChat(
430 container = container,
431 onOpenFiles = { navController.navigate(Routes.FILES) },
432 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
433 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
434 onOpenSearch = { navController.navigate(Routes.SEARCH) },
435 onDisconnect = {
436 scope.launch { runCatchingCancellable { container.disconnect() } }
437 navController.popBackStack(Routes.SERVERS, inclusive = false)
438 },
439 onAddServer = { navController.navigate(Routes.SERVER_EDIT) },
440 onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
441 )
442 } else {
443 SessionListScreen(
444 container = container,
445 onOpenSession = { id ->
446 // Set the draft synchronously in-memory before navigating so the
447 // ChatScreen sees it on first composition. The async persistence
448 // to disk happens in the background via draftStore.set.
449 container.consumePendingShare()?.let { shareText ->
450 container.draftStore.setImmediate(id, shareText)
451 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
452 }
453 navController.navigate(Routes.chat(id))
454 },
455 onDisconnect = {
456 scope.launch { runCatchingCancellable { container.disconnect() } }
457 navController.popBackStack(Routes.SERVERS, inclusive = false)
458 },
459 onOpenFiles = { navController.navigate(Routes.FILES) },
460 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
461 onOpenSearch = { navController.navigate(Routes.SEARCH) },
462 onAddServer = { navController.navigate(Routes.SERVER_EDIT) },
463 onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
464 )
465 }
466 }
468 composable(
469 route = "${Routes.CHAT}/{sessionId}?focus={focus}",
470 arguments = listOf(
471 navArgument("sessionId") { type = NavType.StringType },
472 navArgument("focus") { type = NavType.StringType; nullable = true; defaultValue = null },
473 ),
474 ) { entry ->
475 ChatScreen(
476 container = container,
477 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
478 focusMessageId = entry.arguments?.getString("focus"),
479 onBack = { navController.popBackStack() },
480 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
481 onOpenSession = { id ->
482 navController.navigate(Routes.chat(id)) {
483 // Prevent the same chat from being pushed twice when re-tapping a
484 // session deep link, and keep the back stack bounded when chaining
485 // chat → linked chat → … (otherwise every hop piles a new entry).
486 launchSingleTop = true
487 }
488 },
489 onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
490 )
491 }
493 composable(Routes.FILES) {
494 if (isTwoPane) {
495 TwoPaneFileBrowser(
496 container = container,
497 onBack = { navController.popBackStack() },
498 )
499 } else {
500 FileBrowserScreen(
501 container = container,
502 onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
503 onBack = { navController.popBackStack() },
504 )
505 }
506 }
508 composable(
509 route = "${Routes.FILE_VIEW}?path={path}&line={line}",
510 arguments = listOf(
511 navArgument("path") { type = NavType.StringType; defaultValue = "" },
512 navArgument("line") { type = NavType.IntType; defaultValue = -1 },
513 ),
514 ) { entry ->
515 val line = entry.arguments?.getInt("line") ?: -1
516 FileViewScreen(
517 container = container,
518 path = entry.arguments?.getString("path").orEmpty(),
519 initialLine = line.takeIf { it > 0 },
520 onBack = { navController.popBackStack() },
521 )
522 }
524 composable(Routes.SETTINGS) {
525 SettingsScreen(
526 container = container,
527 onBack = { navController.popBackStack() },
528 // Navigate to the server list as a new entry on top of Sessions so the
529 // user can back out to their conversations. Previously this popped back
530 // to the root SERVERS destination, which removed Sessions from the stack
531 // and left the user unable to return to their conversations without
532 // reconnecting. Pop only Settings, then push SERVERS; back from the
533 // server list lands on Sessions (the screen the user was on before
534 // Settings), and connecting pushes a fresh SESSIONS as usual.
535 onManageServers = {
536 navController.navigate(Routes.SERVERS) {
537 popUpTo(Routes.SETTINGS) { inclusive = true }
538 launchSingleTop = true
539 }
540 },
541 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
542 onOpenUsage = { navController.navigate(Routes.USAGE) },
543 onOpenMcp = { navController.navigate(Routes.MCP) },
544 onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
545 )
546 }
548 composable(Routes.DIAGNOSTICS) {
549 DiagnosticsScreen(onBack = { navController.popBackStack() })
550 }
552 composable(Routes.USAGE) {
553 UsageScreen(
554 container = container,
555 onBack = { navController.popBackStack() },
556 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
557 )
558 }
560 composable(Routes.MCP) {
561 McpScreen(container = container, onBack = { navController.popBackStack() })
562 }
564 composable(Routes.SEARCH) {
565 GlobalSearchScreen(
566 container = container,
567 onOpenSession = { id, focusMessageId -> navController.navigate(Routes.chat(id, focusMessageId)) },
568 onBack = { navController.popBackStack() },
569 )
570 }
571 }
572 }
574 // Collect unread/run state for nav badges (rail on large screens, bottom bar on
575 // compact screens), so a user on Files/Settings sees pending activity at a glance.
576 val unread by container.unread.collectAsStateWithLifecycle()
577 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
578 val totalUnread = remember(unread) { unread.values.sum() }
580 if (showNavRail) {
581 Row(modifier = Modifier.fillMaxSize()) {
582 LargeScreenNavRail(
583 currentRoute = currentRoute,
584 connected = connection != null,
585 unreadCount = totalUnread,
586 runActive = anyRunActive,
587 // Use the standard M3 nav-bar pattern: popUpTo the start destination
588 // saving state, and restoreState on re-entry, so switching between
589 // top-level tabs via the rail preserves each tab's scroll/selection
590 // state and back always exits cleanly instead of popping through
591 // every sibling tab the user visited.
592 onNavigate = { route ->
593 navController.navigate(route) {
594 popUpTo(navController.graph.findStartDestination().id) {
595 saveState = true
596 }
597 launchSingleTop = true
598 restoreState = true
599 }
600 },
601 )
602 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
603 }
604 } else {
605 // Compact screens: a bottom NavigationBar surfaces the top-level destinations one
606 // tap away (Sessions/Search/Files/Settings/Servers) instead of buried in each
607 // screen's overflow menu. The navHost fills the space above the bar; the bar's
608 // own height is accounted for by the NavigationBar's intrinsic measurement.
609 androidx.compose.foundation.layout.Column(modifier = Modifier.fillMaxSize()) {
610 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
611 CompactNavBar(
612 currentRoute = currentRoute,
613 connected = connection != null,
614 unreadCount = totalUnread,
615 runActive = anyRunActive,
616 onNavigate = { route ->
617 navController.navigate(route) {
618 popUpTo(navController.graph.findStartDestination().id) {
619 saveState = true
620 }
621 launchSingleTop = true
622 restoreState = true
623 }
624 },
625 )
626 }
627 }
628 }
629 }