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
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.
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.
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 }
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 }
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
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 }
151 navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
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) {
164 chatTextScale.coerceAtMost(
165 (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
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,
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)
196 foldState = info.displayFeatures
197 .filterIsInstance<androidx.window.layout.FoldingFeature>()
202 val isHalfOpened = foldState?.state == androidx.window.layout.FoldingFeature.State.HALF_OPENED
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
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 // Ctrl+7 navigates to the server list (the 7th nav-rail destination).
221 // Previously Ctrl+1..6 covered the other destinations but Servers was
222 // missing, an inconsistency since the nav rail shows 7 destinations.
223 Key.NumPad7, Key.Seven -> Routes.SERVERS
227 // Servers is reachable even without an active connection (it's the start
228 // destination and handles the not-connected state); the other routes
229 // require a connection to be meaningful.
230 if (route == Routes.SERVERS || connection != null) {
231 navController.navigate(route) {
232 popUpTo(navController.graph.findStartDestination().id) { saveState = true }
233 launchSingleTop = true
241 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp &&
242 connection != null &&
244 // On large screens, surface a navigation rail alongside the content so top-level
245 // destinations (Sessions, Files, Search, …) are one tap away instead of buried in a
246 // screen's overflow menu. Mirrors the standard large-screen M3 layout.
247 val showNavRail = maxWidth >= NetworkConfig.navigationRailThresholdDp.dp
248 // Observe the current destination reactively so the rail's selected item tracks navigation
249 // (reading currentDestination once would go stale after the first composition).
250 val currentEntry by navController.currentBackStackEntryAsState()
251 val currentRoute = currentEntry?.destination?.route
253 // Open a session requested by a notification tap or deep link, once connected.
254 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
255 val pending = pendingOpenSession ?: return@LaunchedEffect
256 if (connection == null) return@LaunchedEffect
257 // If the notification body-tap carries a profile id from a different server,
258 // switch to it first so the session opens under the server that ran it, not
259 // whichever is active. The effect re-runs when connection changes (keyed on it),
260 // so on the second run the profile matches and the open proceeds.
261 if (pending.profileId != null && connection?.profile?.id != pending.profileId) {
262 if (!container.connectByProfileId(pending.profileId)) return@LaunchedEffect
263 return@LaunchedEffect // re-run on the new connection
265 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
266 // route) consumes the request and opens it in the detail pane. But it only
267 // exists while the NavHost is on SESSIONS, so if the user is currently on
268 // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
269 // pending request is never consumed and the session never opens. Don't consume
270 // here; let TwoPaneSessionChat's effect do it once it's composed.
272 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
273 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
275 return@LaunchedEffect
277 container.consumePendingOpenSession()
278 // Ensure SESSIONS sits on the back stack below the chat destination so backing
279 // out of the chat lands on the session list (not the server list). Bring SESSIONS
280 // to front (or push it if a cold-start deep link left only SERVERS on the stack),
281 // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
282 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
283 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
285 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
288 // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
289 // connection exists (the viewer fetches content from the server). Path comes pre-validated
290 // by MainActivity's deep-link guard. In two-pane mode the detail pane (TwoPaneFileBrowser,
291 // hosted on the FILES route) consumes the request into its detail pane, mirroring how
292 // session deep links are routed — so don't consume or push the full-screen viewer here.
293 LaunchedEffect(pendingOpenFile, connection, isTwoPane) {
294 val path = pendingOpenFile ?: return@LaunchedEffect
295 if (connection == null) return@LaunchedEffect
297 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
298 navController.navigate(Routes.FILES) { launchSingleTop = true }
300 return@LaunchedEffect
302 container.consumePendingOpenFile()
303 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
304 navController.navigate(Routes.FILES) { launchSingleTop = true }
306 navController.navigate(Routes.fileView(path)) { launchSingleTop = true }
309 val navHost = @Composable {
311 modifier = Modifier.fillMaxSize(),
312 navController = navController,
313 startDestination = Routes.SERVERS,
314 // Motion-aware transitions: respect the user's reduced-motion setting
315 // (Developer Options → animator scale 0) by skipping animations entirely.
316 // Per-destination tuning: a chat→chat navigation (switching peer conversations
317 // within the same surface) crossfades instead of sliding, so it reads as a peer
318 // switch rather than drilling into a new section. Top-level destinations and
319 // detail pushes keep the horizontal slide.
323 } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
324 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
326 slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
327 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
334 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
337 popEnterTransition = {
341 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
344 popExitTransition = {
348 slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
349 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
354 composable(Routes.SERVERS) {
356 container = container,
358 // Only leave the server list if we're still on it. A pending deep-link /
359 // notification open sets activeConnection (which drives the open-session
360 // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
361 // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
362 // on top of the chat it just opened, burying it. Order-independent: if the
363 // deep link already navigated away from SERVERS, this is a no-op.
364 if (navController.currentDestination?.route == Routes.SERVERS) {
365 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
368 onAddProfile = { navController.navigate(Routes.serverEdit()) },
369 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
370 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
375 route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
377 navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
378 navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
382 container = container,
383 profileId = entry.arguments?.getString("id"),
384 sourceId = entry.arguments?.getString("dup"),
385 onDone = { navController.popBackStack() },
389 composable(Routes.SESSIONS) {
392 container = container,
393 onOpenFiles = { navController.navigate(Routes.FILES) },
394 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
395 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
396 onOpenSearch = { navController.navigate(Routes.SEARCH) },
398 scope.launch { runCatchingCancellable { container.disconnect() } }
399 navController.popBackStack(Routes.SERVERS, inclusive = false)
401 onAddServer = { navController.navigate(Routes.serverEdit()) },
405 container = container,
406 onOpenSession = { id ->
407 // Set the draft synchronously in-memory before navigating so the
408 // ChatScreen sees it on first composition. The async persistence
409 // to disk happens in the background via draftStore.set.
410 container.consumePendingShare()?.let { shareText ->
411 container.draftStore.setImmediate(id, shareText)
412 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
414 navController.navigate(Routes.chat(id))
417 scope.launch { runCatchingCancellable { container.disconnect() } }
418 navController.popBackStack(Routes.SERVERS, inclusive = false)
420 onOpenFiles = { navController.navigate(Routes.FILES) },
421 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
422 onOpenSearch = { navController.navigate(Routes.SEARCH) },
423 onAddServer = { navController.navigate(Routes.serverEdit()) },
429 route = "${Routes.CHAT}/{sessionId}",
430 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
433 container = container,
434 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
435 onBack = { navController.popBackStack() },
436 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
437 onOpenSession = { id ->
438 navController.navigate(Routes.chat(id)) {
439 // Prevent the same chat from being pushed twice when re-tapping a
440 // session deep link, and keep the back stack bounded when chaining
441 // chat → linked chat → … (otherwise every hop piles a new entry).
442 launchSingleTop = true
448 composable(Routes.FILES) {
451 container = container,
452 onBack = { navController.popBackStack() },
456 container = container,
457 onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
458 onBack = { navController.popBackStack() },
464 route = "${Routes.FILE_VIEW}?path={path}&line={line}",
466 navArgument("path") { type = NavType.StringType; defaultValue = "" },
467 navArgument("line") { type = NavType.IntType; defaultValue = -1 },
470 val line = entry.arguments?.getInt("line") ?: -1
472 container = container,
473 path = entry.arguments?.getString("path").orEmpty(),
474 initialLine = line.takeIf { it > 0 },
475 onBack = { navController.popBackStack() },
479 composable(Routes.SETTINGS) {
481 container = container,
482 onBack = { navController.popBackStack() },
483 // Navigate to the server list as a new entry on top of Sessions so the
484 // user can back out to their conversations. Previously this popped back
485 // to the root SERVERS destination, which removed Sessions from the stack
486 // and left the user unable to return to their conversations without
487 // reconnecting. Pop only Settings, then push SERVERS; back from the
488 // server list lands on Sessions (the screen the user was on before
489 // Settings), and connecting pushes a fresh SESSIONS as usual.
491 navController.navigate(Routes.SERVERS) {
492 popUpTo(Routes.SETTINGS) { inclusive = true }
493 launchSingleTop = true
496 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
497 onOpenUsage = { navController.navigate(Routes.USAGE) },
498 onOpenMcp = { navController.navigate(Routes.MCP) },
502 composable(Routes.DIAGNOSTICS) {
503 DiagnosticsScreen(onBack = { navController.popBackStack() })
506 composable(Routes.USAGE) {
508 container = container,
509 onBack = { navController.popBackStack() },
510 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
514 composable(Routes.MCP) {
515 McpScreen(container = container, onBack = { navController.popBackStack() })
518 composable(Routes.SEARCH) {
520 container = container,
521 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
522 onBack = { navController.popBackStack() },
529 // Collect unread/run state for the nav rail's Sessions badge, so a user on
530 // Files/Settings sees pending activity at a glance.
531 val unread by container.unread.collectAsStateWithLifecycle()
532 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
533 val totalUnread = remember(unread) { unread.values.sum() }
534 Row(modifier = Modifier.fillMaxSize()) {
536 currentRoute = currentRoute,
537 connected = connection != null,
538 unreadCount = totalUnread,
539 runActive = anyRunActive,
540 // Use the standard M3 nav-bar pattern: popUpTo the start destination
541 // saving state, and restoreState on re-entry, so switching between
542 // top-level tabs via the rail preserves each tab's scroll/selection
543 // state and back always exits cleanly instead of popping through
544 // every sibling tab the user visited.
545 onNavigate = { route ->
546 navController.navigate(route) {
547 popUpTo(navController.graph.findStartDestination().id) {
550 launchSingleTop = true
555 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
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