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.rememberCoroutineScope
21import androidx.compose.runtime.saveable.rememberSaveable
22import androidx.compose.runtime.setValue
23import androidx.compose.ui.Modifier
24import androidx.compose.ui.platform.LocalDensity
25import androidx.compose.ui.unit.dp
26import androidx.compose.ui.unit.LayoutDirection
27import androidx.compose.ui.platform.LocalLayoutDirection
28import androidx.navigation.NavType
29import androidx.navigation.compose.NavHost
30import androidx.navigation.compose.composable
31import androidx.navigation.compose.currentBackStackEntryAsState
32import androidx.navigation.compose.rememberNavController
33import androidx.navigation.navArgument
34import androidx.lifecycle.compose.collectAsStateWithLifecycle
35import kotlinx.coroutines.launch
36import soy.iko.opencode.di.AppContainer
37import soy.iko.opencode.data.network.NetworkConfig
38import soy.iko.opencode.ui.chat.ChatScreen
39import soy.iko.opencode.ui.file.FileBrowserScreen
40import soy.iko.opencode.ui.file.FileViewScreen
41import soy.iko.opencode.ui.file.TwoPaneFileBrowser
42import soy.iko.opencode.ui.server.ServerEditScreen
43import soy.iko.opencode.ui.server.ServerListScreen
44import soy.iko.opencode.ui.search.GlobalSearchScreen
45import soy.iko.opencode.ui.components.LargeScreenNavRail
46import soy.iko.opencode.ui.components.LocalChatTextScale
47import soy.iko.opencode.ui.components.LocalCodeWrap
48import soy.iko.opencode.ui.components.LocalReducedMotion
49import soy.iko.opencode.ui.components.isReducedMotion
50import soy.iko.opencode.ui.session.SessionListScreen
51import soy.iko.opencode.ui.session.TwoPaneSessionChat
52import soy.iko.opencode.ui.settings.DiagnosticsScreen
53import soy.iko.opencode.ui.settings.SettingsScreen
54import soy.iko.opencode.ui.usage.UsageScreen
55import soy.iko.opencode.ui.mcp.McpScreen
56import soy.iko.opencode.util.runCatchingCancellable
59fun OpencodeApp(container: AppContainer) {
60 val navController = rememberNavController()
61 val scope = rememberCoroutineScope()
62 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
63 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
64 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
65 val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
66 val pendingOpenFile by container.pendingOpenFile.collectAsStateWithLifecycle()
67 val pendingDiagnostics by container.pendingDiagnostics.collectAsStateWithLifecycle()
68 val connection by container.activeConnection.collectAsStateWithLifecycle()
69 // Chat presentation preferences, provided to the whole nav graph so the markdown/code
70 // renderers honor the user's text-size and code-wrap choices everywhere.
71 val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
72 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
74 // The foreground service that holds priority while any run is active is driven from the
75 // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
76 // from here — a composition-scoped collector pauses while backgrounded, which is exactly
77 // when a run started via the notification reply needs the service to start.
79 // When text is shared into the app, surface the session list so the user can pick
80 // (or create) a conversation to drop it into. The chosen session's draft is set
81 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
82 // before auto-reconnect completes is retried once the connection is established.
83 // Track the last-handled share payloads (there's no share nonce on the container) so a
84 // *new* share arriving while already connected — a warm start where onNewIntent re-fires
85 // setPendingShare under an unchanged connection id — still navigates, while a re-trigger
86 // carrying no new share (a bare server switch, which routes through a null connection, or
87 // a share the user already navigated for) leaves them where they are.
89 // Reset the remembered media when the pending list is drained (the chosen ChatScreen
90 // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
91 // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
92 // "already handled", silently skipping the session-list navigation the second time.
94 // rememberSaveable (not remember) so a process-kill restore doesn't reset the dedup
95 // state and re-navigate to SESSIONS for a share the user already handled before the kill.
96 var handledShareText by rememberSaveable { mutableStateOf<String?>(null) }
97 var handledSharedMedia by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
98 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
99 // Reset the remembered handled state for each channel independently as it drains,
100 // so re-sharing the same payload after consumption is still recognized as new. A
101 // joint reset (only when both are empty) would drop a text-only re-share that
102 // follows a text+media share of the same text: the text channel matches its
103 // remembered value while the media channel is empty (not "new"), so neither clause
104 // fires and the share is silently swallowed.
105 if (pendingShare == null) handledShareText = null
106 if (pendingSharedMedia.isEmpty()) handledSharedMedia = emptyList()
107 if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
108 if (connection == null) return@LaunchedEffect
109 val newShare = (pendingShare != null && pendingShare != handledShareText) ||
110 (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
111 if (!newShare) return@LaunchedEffect
112 handledShareText = pendingShare
113 handledSharedMedia = pendingSharedMedia
114 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
115 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
119 // Crash-relaunch prompt: navigate to the Diagnostics screen when the user taps
120 // "View report" on the cold-start crash dialog. Doesn't require a connection.
121 LaunchedEffect(pendingDiagnostics) {
122 if (!pendingDiagnostics) return@LaunchedEffect
123 container.consumePendingDiagnostics()
124 navController.navigate(Routes.DIAGNOSTICS) { launchSingleTop = true }
127 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
128 // session and open it. The pending counter is consumed only *after* createSession
129 // succeeds, so a transient failure leaves the request pending to retry (on the next
130 // connection change or a re-tap that increments the counter) instead of silently
132 LaunchedEffect(pendingNewSession, connection) {
133 if (pendingNewSession == 0) return@LaunchedEffect
134 val conn = connection ?: return@LaunchedEffect
135 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
136 ?: return@LaunchedEffect
137 if (!container.consumePendingNewSession()) return@LaunchedEffect
138 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
139 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
141 navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
144 // The chat text-size preference multiplies on top of the OS font scale (sp already
145 // honors the system setting), so a large system size × large app size can reach ~2×
146 // and break tight layouts. Clamp the provided app scale so the product with the OS
147 // fontScale never exceeds NetworkConfig.maxCombinedFontScale, while never scaling
148 // below the user's chosen value's intent (floor of 1× app scale is preserved when the
149 // system size alone already exceeds the cap).
150 val systemFontScale = LocalDensity.current.fontScale
151 val clampedChatTextScale = if (systemFontScale <= 0f) {
154 chatTextScale.coerceAtMost(
155 (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
159 // Capture the reduced-motion preference once (in the composable scope) so the
160 // non-composable NavHost transition lambdas can use it to skip animations, and provide
161 // it via LocalReducedMotion so in-composition animations (chat row placement, banners,
162 // image zoom) honor the same accessibility preference.
163 val reducedMotion = isReducedMotion()
164 CompositionLocalProvider(
165 LocalChatTextScale provides clampedChatTextScale,
166 LocalCodeWrap provides codeWrap,
167 LocalReducedMotion provides reducedMotion,
169 // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
170 // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
171 val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
172 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
173 // the open conversation side by side instead of a single-pane back stack.
175 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
176 // On large screens, surface a navigation rail alongside the content so top-level
177 // destinations (Sessions, Files, Search, …) are one tap away instead of buried in a
178 // screen's overflow menu. Mirrors the standard large-screen M3 layout.
179 val showNavRail = maxWidth >= NetworkConfig.navigationRailThresholdDp.dp
180 // Observe the current destination reactively so the rail's selected item tracks navigation
181 // (reading currentDestination once would go stale after the first composition).
182 val currentEntry by navController.currentBackStackEntryAsState()
183 val currentRoute = currentEntry?.destination?.route
185 // Open a session requested by a notification tap or deep link, once connected.
186 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
187 val pending = pendingOpenSession ?: return@LaunchedEffect
188 if (connection == null) return@LaunchedEffect
189 // If the notification body-tap carries a profile id from a different server,
190 // switch to it first so the session opens under the server that ran it, not
191 // whichever is active. The effect re-runs when connection changes (keyed on it),
192 // so on the second run the profile matches and the open proceeds.
193 if (pending.profileId != null && connection?.profile?.id != pending.profileId) {
194 if (!container.connectByProfileId(pending.profileId)) return@LaunchedEffect
195 return@LaunchedEffect // re-run on the new connection
197 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
198 // route) consumes the request and opens it in the detail pane. But it only
199 // exists while the NavHost is on SESSIONS, so if the user is currently on
200 // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
201 // pending request is never consumed and the session never opens. Don't consume
202 // here; let TwoPaneSessionChat's effect do it once it's composed.
204 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
205 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
207 return@LaunchedEffect
209 container.consumePendingOpenSession()
210 // Ensure SESSIONS sits on the back stack below the chat destination so backing
211 // out of the chat lands on the session list (not the server list). Bring SESSIONS
212 // to front (or push it if a cold-start deep link left only SERVERS on the stack),
213 // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
214 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
215 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
217 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
220 // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
221 // connection exists (the viewer fetches content from the server). Path comes pre-validated
222 // by MainActivity's deep-link guard. In two-pane mode the detail pane (TwoPaneFileBrowser,
223 // hosted on the FILES route) consumes the request into its detail pane, mirroring how
224 // session deep links are routed — so don't consume or push the full-screen viewer here.
225 LaunchedEffect(pendingOpenFile, connection, isTwoPane) {
226 val path = pendingOpenFile ?: return@LaunchedEffect
227 if (connection == null) return@LaunchedEffect
229 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
230 navController.navigate(Routes.FILES) { launchSingleTop = true }
232 return@LaunchedEffect
234 container.consumePendingOpenFile()
235 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
236 navController.navigate(Routes.FILES) { launchSingleTop = true }
238 navController.navigate(Routes.fileView(path)) { launchSingleTop = true }
241 val navHost = @Composable {
243 modifier = Modifier.fillMaxSize(),
244 navController = navController,
245 startDestination = Routes.SERVERS,
246 // Motion-aware transitions: respect the user's reduced-motion setting
247 // (Developer Options → animator scale 0) by skipping animations entirely.
248 // Per-destination tuning: a chat→chat navigation (switching peer conversations
249 // within the same surface) crossfades instead of sliding, so it reads as a peer
250 // switch rather than drilling into a new section. Top-level destinations and
251 // detail pushes keep the horizontal slide.
255 } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
256 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
258 slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
259 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
266 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
269 popEnterTransition = {
273 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
276 popExitTransition = {
280 slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
281 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
286 composable(Routes.SERVERS) {
288 container = container,
290 // Only leave the server list if we're still on it. A pending deep-link /
291 // notification open sets activeConnection (which drives the open-session
292 // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
293 // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
294 // on top of the chat it just opened, burying it. Order-independent: if the
295 // deep link already navigated away from SERVERS, this is a no-op.
296 if (navController.currentDestination?.route == Routes.SERVERS) {
297 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
300 onAddProfile = { navController.navigate(Routes.serverEdit()) },
301 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
302 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
307 route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
309 navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
310 navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
314 container = container,
315 profileId = entry.arguments?.getString("id"),
316 sourceId = entry.arguments?.getString("dup"),
317 onDone = { navController.popBackStack() },
321 composable(Routes.SESSIONS) {
324 container = container,
325 onOpenFiles = { navController.navigate(Routes.FILES) },
326 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
327 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
328 onOpenSearch = { navController.navigate(Routes.SEARCH) },
330 scope.launch { runCatchingCancellable { container.disconnect() } }
331 navController.popBackStack(Routes.SERVERS, inclusive = false)
333 onAddServer = { navController.navigate(Routes.serverEdit()) },
337 container = container,
338 onOpenSession = { id ->
339 // Set the draft synchronously in-memory before navigating so the
340 // ChatScreen sees it on first composition. The async persistence
341 // to disk happens in the background via draftStore.set.
342 container.consumePendingShare()?.let { shareText ->
343 container.draftStore.setImmediate(id, shareText)
344 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
346 navController.navigate(Routes.chat(id))
349 scope.launch { runCatchingCancellable { container.disconnect() } }
350 navController.popBackStack(Routes.SERVERS, inclusive = false)
352 onOpenFiles = { navController.navigate(Routes.FILES) },
353 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
354 onOpenSearch = { navController.navigate(Routes.SEARCH) },
355 onAddServer = { navController.navigate(Routes.serverEdit()) },
361 route = "${Routes.CHAT}/{sessionId}",
362 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
365 container = container,
366 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
367 onBack = { navController.popBackStack() },
368 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
369 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
373 composable(Routes.FILES) {
376 container = container,
377 onBack = { navController.popBackStack() },
381 container = container,
382 onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
383 onBack = { navController.popBackStack() },
389 route = "${Routes.FILE_VIEW}?path={path}&line={line}",
391 navArgument("path") { type = NavType.StringType; defaultValue = "" },
392 navArgument("line") { type = NavType.IntType; defaultValue = -1 },
395 val line = entry.arguments?.getInt("line") ?: -1
397 container = container,
398 path = entry.arguments?.getString("path").orEmpty(),
399 initialLine = line.takeIf { it > 0 },
400 onBack = { navController.popBackStack() },
404 composable(Routes.SETTINGS) {
406 container = container,
407 onBack = { navController.popBackStack() },
408 // Navigate to the server list as a new entry on top of Sessions so the
409 // user can back out to their conversations. Previously this popped back
410 // to the root SERVERS destination, which removed Sessions from the stack
411 // and left the user unable to return to their conversations without
412 // reconnecting. Pop only Settings, then push SERVERS; back from the
413 // server list lands on Sessions (the screen the user was on before
414 // Settings), and connecting pushes a fresh SESSIONS as usual.
416 navController.navigate(Routes.SERVERS) {
417 popUpTo(Routes.SETTINGS) { inclusive = true }
418 launchSingleTop = true
421 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
422 onOpenUsage = { navController.navigate(Routes.USAGE) },
423 onOpenMcp = { navController.navigate(Routes.MCP) },
427 composable(Routes.DIAGNOSTICS) {
428 DiagnosticsScreen(onBack = { navController.popBackStack() })
431 composable(Routes.USAGE) {
433 container = container,
434 onBack = { navController.popBackStack() },
435 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
439 composable(Routes.MCP) {
440 McpScreen(container = container, onBack = { navController.popBackStack() })
443 composable(Routes.SEARCH) {
445 container = container,
446 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
447 onBack = { navController.popBackStack() },
454 Row(modifier = Modifier.fillMaxSize()) {
456 currentRoute = currentRoute,
457 connected = connection != null,
458 onNavigate = { route -> navController.navigate(route) { launchSingleTop = true } },
460 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