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