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.LaunchedEffect
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableStateOf
13import androidx.compose.runtime.remember
14import androidx.compose.runtime.rememberCoroutineScope
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.unit.dp
17import androidx.navigation.NavType
18import androidx.navigation.compose.NavHost
19import androidx.navigation.compose.composable
20import androidx.navigation.compose.rememberNavController
21import androidx.navigation.navArgument
22import androidx.lifecycle.compose.collectAsStateWithLifecycle
23import kotlinx.coroutines.launch
24import soy.iko.opencode.di.AppContainer
25import soy.iko.opencode.data.network.NetworkConfig
26import soy.iko.opencode.ui.chat.ChatScreen
27import soy.iko.opencode.ui.file.FileBrowserScreen
28import soy.iko.opencode.ui.file.FileViewScreen
29import soy.iko.opencode.ui.server.ServerEditScreen
30import soy.iko.opencode.ui.server.ServerListScreen
31import soy.iko.opencode.ui.session.SessionListScreen
32import soy.iko.opencode.ui.session.TwoPaneSessionChat
33import soy.iko.opencode.ui.settings.DiagnosticsScreen
34import soy.iko.opencode.ui.settings.SettingsScreen
35import soy.iko.opencode.util.runCatchingCancellable
38fun OpencodeApp(container: AppContainer) {
39 val navController = rememberNavController()
40 val scope = rememberCoroutineScope()
41 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
42 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
43 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
44 val connection by container.activeConnection.collectAsStateWithLifecycle()
46 // Hold a foreground priority for as long as ANY session is actively running — not just
47 // while a running chat is on screen — so a run started and then backgrounded (or left
48 // for the session list) still keeps the SSE stream alive to deliver its completion
49 // notification. Doze/app-standby would otherwise choke the socket once priority drops.
50 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
51 val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
52 LaunchedEffect(anyRunActive) {
54 soy.iko.opencode.notification.RunForegroundService.start(appContext)
56 soy.iko.opencode.notification.RunForegroundService.stop(appContext)
60 // When text is shared into the app, surface the session list so the user can pick
61 // (or create) a conversation to drop it into. The chosen session's draft is set
62 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
63 // before auto-reconnect completes is retried once the connection is established.
64 // Tracks the last connection id seen so a server switch (a non-null -> non-null
65 // change) doesn't re-bounce the user to the session list for a share they've
66 // already decided to abandon; only the null -> non-null initial connect retries.
67 var lastConnectionId by remember { mutableStateOf<String?>(null) }
68 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
69 if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
70 if (connection == null) { lastConnectionId = null; return@LaunchedEffect }
71 val currentId = connection?.profile?.id
72 // Only (re)navigate on the initial null -> non-null connect. A subsequent
73 // server switch while a share is pending (rare) leaves the user where they
74 // are instead of yanking them back to the session list.
75 if (lastConnectionId != null && lastConnectionId == currentId) return@LaunchedEffect
76 lastConnectionId = currentId
77 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
78 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
82 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
83 // the open conversation side by side instead of a single-pane back stack.
85 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
87 // Open a session requested by a notification tap or deep link, once connected.
88 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
89 val id = pendingOpenSession ?: return@LaunchedEffect
90 if (connection == null) return@LaunchedEffect
91 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
92 // route) consumes the request and opens it in the detail pane. But it only
93 // exists while the NavHost is on SESSIONS, so if the user is currently on
94 // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
95 // pending request is never consumed and the session never opens. Don't consume
96 // here; let TwoPaneSessionChat's effect do it once it's composed.
98 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
99 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
101 return@LaunchedEffect
103 container.consumePendingOpenSession()
104 // Ensure SESSIONS sits on the back stack below the chat destination so backing
105 // out of the chat lands on the session list (not the server list). Bring SESSIONS
106 // to front (or push it if a cold-start deep link left only SERVERS on the stack),
107 // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
108 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
109 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
111 navController.navigate(Routes.chat(id)) { launchSingleTop = true }
115 navController = navController,
116 startDestination = Routes.SERVERS,
117 // Slide horizontally for forward/back navigation to feel native; fade for
118 // the root so the first frame doesn't slide in from off-screen.
119 enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
120 exitTransition = { fadeOut(tween(180)) },
121 popEnterTransition = { fadeIn(tween(180)) },
122 popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
125 composable(Routes.SERVERS) {
127 container = container,
129 // Only leave the server list if we're still on it. A pending deep-link /
130 // notification open sets activeConnection (which drives the open-session
131 // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
132 // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
133 // on top of the chat it just opened, burying it. Order-independent: if the
134 // deep link already navigated away from SERVERS, this is a no-op.
135 if (navController.currentDestination?.route == Routes.SERVERS) {
136 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
139 onAddProfile = { navController.navigate(Routes.serverEdit()) },
140 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
141 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
146 route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
148 navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
149 navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
153 container = container,
154 profileId = entry.arguments?.getString("id"),
155 sourceId = entry.arguments?.getString("dup"),
156 onDone = { navController.popBackStack() },
160 composable(Routes.SESSIONS) {
163 container = container,
164 onOpenFiles = { navController.navigate(Routes.FILES) },
165 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
166 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
168 scope.launch { runCatchingCancellable { container.disconnect() } }
169 navController.popBackStack(Routes.SERVERS, inclusive = false)
171 onAddServer = { navController.navigate(Routes.serverEdit()) },
175 container = container,
176 onOpenSession = { id ->
177 // Set the draft synchronously in-memory before navigating so the
178 // ChatScreen sees it on first composition. The async persistence
179 // to disk happens in the background via draftStore.set.
180 container.consumePendingShare()?.let { shareText ->
181 container.draftStore.setImmediate(id, shareText)
182 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
184 navController.navigate(Routes.chat(id))
187 scope.launch { runCatchingCancellable { container.disconnect() } }
188 navController.popBackStack(Routes.SERVERS, inclusive = false)
190 onOpenFiles = { navController.navigate(Routes.FILES) },
191 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
192 onAddServer = { navController.navigate(Routes.serverEdit()) },
198 route = "${Routes.CHAT}/{sessionId}",
199 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
202 container = container,
203 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
204 onBack = { navController.popBackStack() },
205 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
209 composable(Routes.FILES) {
211 container = container,
212 onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
213 onBack = { navController.popBackStack() },
218 route = "${Routes.FILE_VIEW}?path={path}&line={line}",
220 navArgument("path") { type = NavType.StringType; defaultValue = "" },
221 navArgument("line") { type = NavType.IntType; defaultValue = -1 },
224 val line = entry.arguments?.getInt("line") ?: -1
226 container = container,
227 path = entry.arguments?.getString("path").orEmpty(),
228 initialLine = line.takeIf { it > 0 },
229 onBack = { navController.popBackStack() },
233 composable(Routes.SETTINGS) {
235 container = container,
236 onBack = { navController.popBackStack() },
237 // Navigate to the server list as a new entry on top of Sessions so the
238 // user can back out to their conversations. Previously this popped back
239 // to the root SERVERS destination, which removed Sessions from the stack
240 // and left the user unable to return to their conversations without
241 // reconnecting. Pop only Settings, then push SERVERS; back from the
242 // server list lands on Sessions (the screen the user was on before
243 // Settings), and connecting pushes a fresh SESSIONS as usual.
245 navController.navigate(Routes.SERVERS) {
246 popUpTo(Routes.SETTINGS) { inclusive = true }
247 launchSingleTop = true
250 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
254 composable(Routes.DIAGNOSTICS) {
255 DiagnosticsScreen(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