git.iko.soy/opencode-android
on 5d56632 help sign in
305 lines 16 kB history raw
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.remember
15import androidx.compose.runtime.rememberCoroutineScope
16import androidx.compose.runtime.setValue
17import androidx.compose.ui.unit.dp
18import androidx.navigation.NavType
19import androidx.navigation.compose.NavHost
20import androidx.navigation.compose.composable
21import androidx.navigation.compose.rememberNavController
22import androidx.navigation.navArgument
23import androidx.lifecycle.compose.collectAsStateWithLifecycle
24import kotlinx.coroutines.launch
25import soy.iko.opencode.di.AppContainer
26import soy.iko.opencode.data.network.NetworkConfig
27import soy.iko.opencode.ui.chat.ChatScreen
28import soy.iko.opencode.ui.file.FileBrowserScreen
29import soy.iko.opencode.ui.file.FileViewScreen
30import soy.iko.opencode.ui.server.ServerEditScreen
31import soy.iko.opencode.ui.server.ServerListScreen
32import soy.iko.opencode.ui.search.GlobalSearchScreen
33import soy.iko.opencode.ui.components.LocalChatTextScale
34import soy.iko.opencode.ui.components.LocalCodeWrap
35import soy.iko.opencode.ui.session.SessionListScreen
36import soy.iko.opencode.ui.session.TwoPaneSessionChat
37import soy.iko.opencode.ui.settings.DiagnosticsScreen
38import soy.iko.opencode.ui.settings.SettingsScreen
39import soy.iko.opencode.ui.usage.UsageScreen
40import soy.iko.opencode.ui.mcp.McpScreen
41import soy.iko.opencode.util.runCatchingCancellable
43@Composable
44fun OpencodeApp(container: AppContainer) {
45 val navController = rememberNavController()
46 val scope = rememberCoroutineScope()
47 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
48 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
49 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
50 val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
51 val connection by container.activeConnection.collectAsStateWithLifecycle()
52 // Chat presentation preferences, provided to the whole nav graph so the markdown/code
53 // renderers honor the user's text-size and code-wrap choices everywhere.
54 val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
55 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
57 // The foreground service that holds priority while any run is active is driven from the
58 // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
59 // from here — a composition-scoped collector pauses while backgrounded, which is exactly
60 // when a run started via the notification reply needs the service to start.
62 // When text is shared into the app, surface the session list so the user can pick
63 // (or create) a conversation to drop it into. The chosen session's draft is set
64 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
65 // before auto-reconnect completes is retried once the connection is established.
66 // Track the last-handled share payloads (there's no share nonce on the container) so a
67 // *new* share arriving while already connected — a warm start where onNewIntent re-fires
68 // setPendingShare under an unchanged connection id — still navigates, while a re-trigger
69 // carrying no new share (a bare server switch, which routes through a null connection, or
70 // a share the user already navigated for) leaves them where they are.
71 var handledShareText by remember { mutableStateOf<String?>(null) }
72 var handledSharedMedia by remember { mutableStateOf<List<String>>(emptyList()) }
73 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
74 if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
75 if (connection == null) return@LaunchedEffect
76 val newShare = (pendingShare != null && pendingShare != handledShareText) ||
77 (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
78 if (!newShare) return@LaunchedEffect
79 handledShareText = pendingShare
80 handledSharedMedia = pendingSharedMedia
81 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
82 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
83 }
84 }
86 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
87 // session and open it. The pending flag is consumed (compareAndSet) only *after*
88 // createSession succeeds, so a transient failure leaves the request pending to retry
89 // (on the next connection change / trigger) instead of silently dropping the tap.
90 LaunchedEffect(pendingNewSession, connection) {
91 if (!pendingNewSession) return@LaunchedEffect
92 val conn = connection ?: return@LaunchedEffect
93 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
94 ?: return@LaunchedEffect
95 if (!container.consumePendingNewSession()) return@LaunchedEffect
96 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
97 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
98 }
99 navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
100 }
102 CompositionLocalProvider(
103 LocalChatTextScale provides chatTextScale,
104 LocalCodeWrap provides codeWrap,
105 ) {
106 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
107 // the open conversation side by side instead of a single-pane back stack.
108 BoxWithConstraints {
109 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
111 // Open a session requested by a notification tap or deep link, once connected.
112 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
113 val id = pendingOpenSession ?: return@LaunchedEffect
114 if (connection == null) return@LaunchedEffect
115 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
116 // route) consumes the request and opens it in the detail pane. But it only
117 // exists while the NavHost is on SESSIONS, so if the user is currently on
118 // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
119 // pending request is never consumed and the session never opens. Don't consume
120 // here; let TwoPaneSessionChat's effect do it once it's composed.
121 if (isTwoPane) {
122 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
123 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
124 }
125 return@LaunchedEffect
126 }
127 container.consumePendingOpenSession()
128 // Ensure SESSIONS sits on the back stack below the chat destination so backing
129 // out of the chat lands on the session list (not the server list). Bring SESSIONS
130 // to front (or push it if a cold-start deep link left only SERVERS on the stack),
131 // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
132 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
133 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
134 }
135 navController.navigate(Routes.chat(id)) { launchSingleTop = true }
136 }
138 NavHost(
139 navController = navController,
140 startDestination = Routes.SERVERS,
141 // Slide horizontally for forward/back navigation to feel native; fade for
142 // the root so the first frame doesn't slide in from off-screen.
143 enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
144 exitTransition = { fadeOut(tween(180)) },
145 popEnterTransition = { fadeIn(tween(180)) },
146 popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
147 ) {
149 composable(Routes.SERVERS) {
150 ServerListScreen(
151 container = container,
152 onConnected = {
153 // Only leave the server list if we're still on it. A pending deep-link /
154 // notification open sets activeConnection (which drives the open-session
155 // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
156 // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
157 // on top of the chat it just opened, burying it. Order-independent: if the
158 // deep link already navigated away from SERVERS, this is a no-op.
159 if (navController.currentDestination?.route == Routes.SERVERS) {
160 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
161 }
162 },
163 onAddProfile = { navController.navigate(Routes.serverEdit()) },
164 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
165 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
166 )
167 }
169 composable(
170 route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
171 arguments = listOf(
172 navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
173 navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
174 ),
175 ) { entry ->
176 ServerEditScreen(
177 container = container,
178 profileId = entry.arguments?.getString("id"),
179 sourceId = entry.arguments?.getString("dup"),
180 onDone = { navController.popBackStack() },
181 )
182 }
184 composable(Routes.SESSIONS) {
185 if (isTwoPane) {
186 TwoPaneSessionChat(
187 container = container,
188 onOpenFiles = { navController.navigate(Routes.FILES) },
189 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
190 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
191 onOpenSearch = { navController.navigate(Routes.SEARCH) },
192 onDisconnect = {
193 scope.launch { runCatchingCancellable { container.disconnect() } }
194 navController.popBackStack(Routes.SERVERS, inclusive = false)
195 },
196 onAddServer = { navController.navigate(Routes.serverEdit()) },
197 )
198 } else {
199 SessionListScreen(
200 container = container,
201 onOpenSession = { id ->
202 // Set the draft synchronously in-memory before navigating so the
203 // ChatScreen sees it on first composition. The async persistence
204 // to disk happens in the background via draftStore.set.
205 container.consumePendingShare()?.let { shareText ->
206 container.draftStore.setImmediate(id, shareText)
207 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
208 }
209 navController.navigate(Routes.chat(id))
210 },
211 onDisconnect = {
212 scope.launch { runCatchingCancellable { container.disconnect() } }
213 navController.popBackStack(Routes.SERVERS, inclusive = false)
214 },
215 onOpenFiles = { navController.navigate(Routes.FILES) },
216 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
217 onOpenSearch = { navController.navigate(Routes.SEARCH) },
218 onAddServer = { navController.navigate(Routes.serverEdit()) },
219 )
220 }
221 }
223 composable(
224 route = "${Routes.CHAT}/{sessionId}",
225 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
226 ) { entry ->
227 ChatScreen(
228 container = container,
229 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
230 onBack = { navController.popBackStack() },
231 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
232 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
233 )
234 }
236 composable(Routes.FILES) {
237 FileBrowserScreen(
238 container = container,
239 onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
240 onBack = { navController.popBackStack() },
241 )
242 }
244 composable(
245 route = "${Routes.FILE_VIEW}?path={path}&line={line}",
246 arguments = listOf(
247 navArgument("path") { type = NavType.StringType; defaultValue = "" },
248 navArgument("line") { type = NavType.IntType; defaultValue = -1 },
249 ),
250 ) { entry ->
251 val line = entry.arguments?.getInt("line") ?: -1
252 FileViewScreen(
253 container = container,
254 path = entry.arguments?.getString("path").orEmpty(),
255 initialLine = line.takeIf { it > 0 },
256 onBack = { navController.popBackStack() },
257 )
258 }
260 composable(Routes.SETTINGS) {
261 SettingsScreen(
262 container = container,
263 onBack = { navController.popBackStack() },
264 // Navigate to the server list as a new entry on top of Sessions so the
265 // user can back out to their conversations. Previously this popped back
266 // to the root SERVERS destination, which removed Sessions from the stack
267 // and left the user unable to return to their conversations without
268 // reconnecting. Pop only Settings, then push SERVERS; back from the
269 // server list lands on Sessions (the screen the user was on before
270 // Settings), and connecting pushes a fresh SESSIONS as usual.
271 onManageServers = {
272 navController.navigate(Routes.SERVERS) {
273 popUpTo(Routes.SETTINGS) { inclusive = true }
274 launchSingleTop = true
275 }
276 },
277 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
278 onOpenUsage = { navController.navigate(Routes.USAGE) },
279 onOpenMcp = { navController.navigate(Routes.MCP) },
280 )
281 }
283 composable(Routes.DIAGNOSTICS) {
284 DiagnosticsScreen(onBack = { navController.popBackStack() })
285 }
287 composable(Routes.USAGE) {
288 UsageScreen(container = container, onBack = { navController.popBackStack() })
289 }
291 composable(Routes.MCP) {
292 McpScreen(container = container, onBack = { navController.popBackStack() })
293 }
295 composable(Routes.SEARCH) {
296 GlobalSearchScreen(
297 container = container,
298 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
299 onBack = { navController.popBackStack() },
300 )
301 }
302 }
303 }
304 }