1package soy.iko.opencode.ui
3import androidx.compose.animation.core.tween
4import androidx.compose.animation.fadeIn
5import androidx.compose.animation.fadeOut
6import androidx.compose.animation.slideInHorizontally
7import androidx.compose.animation.slideOutHorizontally
8import androidx.compose.foundation.layout.BoxWithConstraints
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.CompositionLocalProvider
11import androidx.compose.runtime.LaunchedEffect
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableStateOf
14import androidx.compose.runtime.rememberCoroutineScope
15import androidx.compose.runtime.saveable.rememberSaveable
16import androidx.compose.runtime.setValue
17import androidx.compose.ui.platform.LocalDensity
18import androidx.compose.ui.unit.dp
19import androidx.navigation.NavType
20import androidx.navigation.compose.NavHost
21import androidx.navigation.compose.composable
22import androidx.navigation.compose.rememberNavController
23import androidx.navigation.navArgument
24import androidx.lifecycle.compose.collectAsStateWithLifecycle
25import kotlinx.coroutines.launch
26import soy.iko.opencode.di.AppContainer
27import soy.iko.opencode.data.network.NetworkConfig
28import soy.iko.opencode.ui.chat.ChatScreen
29import soy.iko.opencode.ui.file.FileBrowserScreen
30import soy.iko.opencode.ui.file.FileViewScreen
31import soy.iko.opencode.ui.server.ServerEditScreen
32import soy.iko.opencode.ui.server.ServerListScreen
33import soy.iko.opencode.ui.search.GlobalSearchScreen
34import soy.iko.opencode.ui.components.LocalChatTextScale
35import soy.iko.opencode.ui.components.LocalCodeWrap
36import soy.iko.opencode.ui.session.SessionListScreen
37import soy.iko.opencode.ui.session.TwoPaneSessionChat
38import soy.iko.opencode.ui.settings.DiagnosticsScreen
39import soy.iko.opencode.ui.settings.SettingsScreen
40import soy.iko.opencode.ui.usage.UsageScreen
41import soy.iko.opencode.ui.mcp.McpScreen
42import soy.iko.opencode.util.runCatchingCancellable
45fun OpencodeApp(container: AppContainer) {
46 val navController = rememberNavController()
47 val scope = rememberCoroutineScope()
48 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
49 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
50 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
51 val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
52 val connection by container.activeConnection.collectAsStateWithLifecycle()
53 // Chat presentation preferences, provided to the whole nav graph so the markdown/code
54 // renderers honor the user's text-size and code-wrap choices everywhere.
55 val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
56 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
58 // The foreground service that holds priority while any run is active is driven from the
59 // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
60 // from here — a composition-scoped collector pauses while backgrounded, which is exactly
61 // when a run started via the notification reply needs the service to start.
63 // When text is shared into the app, surface the session list so the user can pick
64 // (or create) a conversation to drop it into. The chosen session's draft is set
65 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
66 // before auto-reconnect completes is retried once the connection is established.
67 // Track the last-handled share payloads (there's no share nonce on the container) so a
68 // *new* share arriving while already connected — a warm start where onNewIntent re-fires
69 // setPendingShare under an unchanged connection id — still navigates, while a re-trigger
70 // carrying no new share (a bare server switch, which routes through a null connection, or
71 // a share the user already navigated for) leaves them where they are.
73 // Reset the remembered media when the pending list is drained (the chosen ChatScreen
74 // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
75 // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
76 // "already handled", silently skipping the session-list navigation the second time.
78 // rememberSaveable (not remember) so a process-kill restore doesn't reset the dedup
79 // state and re-navigate to SESSIONS for a share the user already handled before the kill.
80 var handledShareText by rememberSaveable { mutableStateOf<String?>(null) }
81 var handledSharedMedia by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
82 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
83 // Reset the remembered handled state for each channel independently as it drains,
84 // so re-sharing the same payload after consumption is still recognized as new. A
85 // joint reset (only when both are empty) would drop a text-only re-share that
86 // follows a text+media share of the same text: the text channel matches its
87 // remembered value while the media channel is empty (not "new"), so neither clause
88 // fires and the share is silently swallowed.
89 if (pendingShare == null) handledShareText = null
90 if (pendingSharedMedia.isEmpty()) handledSharedMedia = emptyList()
91 if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
92 if (connection == null) return@LaunchedEffect
93 val newShare = (pendingShare != null && pendingShare != handledShareText) ||
94 (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
95 if (!newShare) return@LaunchedEffect
96 handledShareText = pendingShare
97 handledSharedMedia = pendingSharedMedia
98 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
99 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
103 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
104 // session and open it. The pending counter is consumed only *after* createSession
105 // succeeds, so a transient failure leaves the request pending to retry (on the next
106 // connection change or a re-tap that increments the counter) instead of silently
108 LaunchedEffect(pendingNewSession, connection) {
109 if (pendingNewSession == 0) return@LaunchedEffect
110 val conn = connection ?: return@LaunchedEffect
111 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
112 ?: return@LaunchedEffect
113 if (!container.consumePendingNewSession()) return@LaunchedEffect
114 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
115 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
117 navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
120 // The chat text-size preference multiplies on top of the OS font scale (sp already
121 // honors the system setting), so a large system size × large app size can reach ~2×
122 // and break tight layouts. Clamp the provided app scale so the product with the OS
123 // fontScale never exceeds NetworkConfig.maxCombinedFontScale, while never scaling
124 // below the user's chosen value's intent (floor of 1× app scale is preserved when the
125 // system size alone already exceeds the cap).
126 val systemFontScale = LocalDensity.current.fontScale
127 val clampedChatTextScale = if (systemFontScale <= 0f) {
130 chatTextScale.coerceAtMost(
131 (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
135 CompositionLocalProvider(
136 LocalChatTextScale provides clampedChatTextScale,
137 LocalCodeWrap provides codeWrap,
139 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
140 // the open conversation side by side instead of a single-pane back stack.
142 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
144 // Open a session requested by a notification tap or deep link, once connected.
145 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
146 val pending = pendingOpenSession ?: return@LaunchedEffect
147 if (connection == null) return@LaunchedEffect
148 // If the notification body-tap carries a profile id from a different server,
149 // switch to it first so the session opens under the server that ran it, not
150 // whichever is active. The effect re-runs when connection changes (keyed on it),
151 // so on the second run the profile matches and the open proceeds.
152 if (pending.profileId != null && connection?.profile?.id != pending.profileId) {
153 if (!container.connectByProfileId(pending.profileId)) return@LaunchedEffect
154 return@LaunchedEffect // re-run on the new connection
156 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
157 // route) consumes the request and opens it in the detail pane. But it only
158 // exists while the NavHost is on SESSIONS, so if the user is currently on
159 // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
160 // pending request is never consumed and the session never opens. Don't consume
161 // here; let TwoPaneSessionChat's effect do it once it's composed.
163 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
164 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
166 return@LaunchedEffect
168 container.consumePendingOpenSession()
169 // Ensure SESSIONS sits on the back stack below the chat destination so backing
170 // out of the chat lands on the session list (not the server list). Bring SESSIONS
171 // to front (or push it if a cold-start deep link left only SERVERS on the stack),
172 // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
173 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
174 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
176 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
180 navController = navController,
181 startDestination = Routes.SERVERS,
182 // Slide horizontally for forward/back navigation to feel native; fade for
183 // the root so the first frame doesn't slide in from off-screen.
185 slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
186 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
188 exitTransition = { fadeOut(tween(NetworkConfig.motionFadeDurationMs)) },
189 popEnterTransition = { fadeIn(tween(NetworkConfig.motionFadeDurationMs)) },
190 popExitTransition = {
191 slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
192 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
196 composable(Routes.SERVERS) {
198 container = container,
200 // Only leave the server list if we're still on it. A pending deep-link /
201 // notification open sets activeConnection (which drives the open-session
202 // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
203 // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
204 // on top of the chat it just opened, burying it. Order-independent: if the
205 // deep link already navigated away from SERVERS, this is a no-op.
206 if (navController.currentDestination?.route == Routes.SERVERS) {
207 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
210 onAddProfile = { navController.navigate(Routes.serverEdit()) },
211 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
212 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
217 route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
219 navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
220 navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
224 container = container,
225 profileId = entry.arguments?.getString("id"),
226 sourceId = entry.arguments?.getString("dup"),
227 onDone = { navController.popBackStack() },
231 composable(Routes.SESSIONS) {
234 container = container,
235 onOpenFiles = { navController.navigate(Routes.FILES) },
236 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
237 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
238 onOpenSearch = { navController.navigate(Routes.SEARCH) },
240 scope.launch { runCatchingCancellable { container.disconnect() } }
241 navController.popBackStack(Routes.SERVERS, inclusive = false)
243 onAddServer = { navController.navigate(Routes.serverEdit()) },
247 container = container,
248 onOpenSession = { id ->
249 // Set the draft synchronously in-memory before navigating so the
250 // ChatScreen sees it on first composition. The async persistence
251 // to disk happens in the background via draftStore.set.
252 container.consumePendingShare()?.let { shareText ->
253 container.draftStore.setImmediate(id, shareText)
254 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
256 navController.navigate(Routes.chat(id))
259 scope.launch { runCatchingCancellable { container.disconnect() } }
260 navController.popBackStack(Routes.SERVERS, inclusive = false)
262 onOpenFiles = { navController.navigate(Routes.FILES) },
263 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
264 onOpenSearch = { navController.navigate(Routes.SEARCH) },
265 onAddServer = { navController.navigate(Routes.serverEdit()) },
271 route = "${Routes.CHAT}/{sessionId}",
272 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
275 container = container,
276 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
277 onBack = { navController.popBackStack() },
278 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
279 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
283 composable(Routes.FILES) {
285 container = container,
286 onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
287 onBack = { navController.popBackStack() },
292 route = "${Routes.FILE_VIEW}?path={path}&line={line}",
294 navArgument("path") { type = NavType.StringType; defaultValue = "" },
295 navArgument("line") { type = NavType.IntType; defaultValue = -1 },
298 val line = entry.arguments?.getInt("line") ?: -1
300 container = container,
301 path = entry.arguments?.getString("path").orEmpty(),
302 initialLine = line.takeIf { it > 0 },
303 onBack = { navController.popBackStack() },
307 composable(Routes.SETTINGS) {
309 container = container,
310 onBack = { navController.popBackStack() },
311 // Navigate to the server list as a new entry on top of Sessions so the
312 // user can back out to their conversations. Previously this popped back
313 // to the root SERVERS destination, which removed Sessions from the stack
314 // and left the user unable to return to their conversations without
315 // reconnecting. Pop only Settings, then push SERVERS; back from the
316 // server list lands on Sessions (the screen the user was on before
317 // Settings), and connecting pushes a fresh SESSIONS as usual.
319 navController.navigate(Routes.SERVERS) {
320 popUpTo(Routes.SETTINGS) { inclusive = true }
321 launchSingleTop = true
324 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
325 onOpenUsage = { navController.navigate(Routes.USAGE) },
326 onOpenMcp = { navController.navigate(Routes.MCP) },
330 composable(Routes.DIAGNOSTICS) {
331 DiagnosticsScreen(onBack = { navController.popBackStack() })
334 composable(Routes.USAGE) {
336 container = container,
337 onBack = { navController.popBackStack() },
338 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
342 composable(Routes.MCP) {
343 McpScreen(container = container, onBack = { navController.popBackStack() })
346 composable(Routes.SEARCH) {
348 container = container,
349 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
350 onBack = { navController.popBackStack() },
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