git.iko.soy/opencode-android
on 0a26f8d help sign in
330 lines 18 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.rememberCoroutineScope
15import androidx.compose.runtime.saveable.rememberSaveable
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 //
72 // Reset the remembered media when the pending list is drained (the chosen ChatScreen
73 // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
74 // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
75 // "already handled", silently skipping the session-list navigation the second time.
76 //
77 // rememberSaveable (not remember) so a process-kill restore doesn't reset the dedup
78 // state and re-navigate to SESSIONS for a share the user already handled before the kill.
79 var handledShareText by rememberSaveable { mutableStateOf<String?>(null) }
80 var handledSharedMedia by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
81 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
82 // Reset the remembered handled state for each channel independently as it drains,
83 // so re-sharing the same payload after consumption is still recognized as new. A
84 // joint reset (only when both are empty) would drop a text-only re-share that
85 // follows a text+media share of the same text: the text channel matches its
86 // remembered value while the media channel is empty (not "new"), so neither clause
87 // fires and the share is silently swallowed.
88 if (pendingShare == null) handledShareText = null
89 if (pendingSharedMedia.isEmpty()) handledSharedMedia = emptyList()
90 if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
91 if (connection == null) return@LaunchedEffect
92 val newShare = (pendingShare != null && pendingShare != handledShareText) ||
93 (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
94 if (!newShare) return@LaunchedEffect
95 handledShareText = pendingShare
96 handledSharedMedia = pendingSharedMedia
97 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
98 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
99 }
100 }
102 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
103 // session and open it. The pending counter is consumed only *after* createSession
104 // succeeds, so a transient failure leaves the request pending to retry (on the next
105 // connection change or a re-tap that increments the counter) instead of silently
106 // dropping the tap.
107 LaunchedEffect(pendingNewSession, connection) {
108 if (pendingNewSession == 0) return@LaunchedEffect
109 val conn = connection ?: return@LaunchedEffect
110 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
111 ?: return@LaunchedEffect
112 if (!container.consumePendingNewSession()) return@LaunchedEffect
113 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
114 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
115 }
116 navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
117 }
119 CompositionLocalProvider(
120 LocalChatTextScale provides chatTextScale,
121 LocalCodeWrap provides codeWrap,
122 ) {
123 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
124 // the open conversation side by side instead of a single-pane back stack.
125 BoxWithConstraints {
126 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
128 // Open a session requested by a notification tap or deep link, once connected.
129 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
130 val pending = pendingOpenSession ?: return@LaunchedEffect
131 if (connection == null) return@LaunchedEffect
132 // If the notification body-tap carries a profile id from a different server,
133 // switch to it first so the session opens under the server that ran it, not
134 // whichever is active. The effect re-runs when connection changes (keyed on it),
135 // so on the second run the profile matches and the open proceeds.
136 if (pending.profileId != null && connection?.profile?.id != pending.profileId) {
137 if (!container.connectByProfileId(pending.profileId)) return@LaunchedEffect
138 return@LaunchedEffect // re-run on the new connection
139 }
140 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
141 // route) consumes the request and opens it in the detail pane. But it only
142 // exists while the NavHost is on SESSIONS, so if the user is currently on
143 // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
144 // pending request is never consumed and the session never opens. Don't consume
145 // here; let TwoPaneSessionChat's effect do it once it's composed.
146 if (isTwoPane) {
147 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
148 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
149 }
150 return@LaunchedEffect
151 }
152 container.consumePendingOpenSession()
153 // Ensure SESSIONS sits on the back stack below the chat destination so backing
154 // out of the chat lands on the session list (not the server list). Bring SESSIONS
155 // to front (or push it if a cold-start deep link left only SERVERS on the stack),
156 // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
157 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
158 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
159 }
160 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
161 }
163 NavHost(
164 navController = navController,
165 startDestination = Routes.SERVERS,
166 // Slide horizontally for forward/back navigation to feel native; fade for
167 // the root so the first frame doesn't slide in from off-screen.
168 enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
169 exitTransition = { fadeOut(tween(180)) },
170 popEnterTransition = { fadeIn(tween(180)) },
171 popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
172 ) {
174 composable(Routes.SERVERS) {
175 ServerListScreen(
176 container = container,
177 onConnected = {
178 // Only leave the server list if we're still on it. A pending deep-link /
179 // notification open sets activeConnection (which drives the open-session
180 // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
181 // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
182 // on top of the chat it just opened, burying it. Order-independent: if the
183 // deep link already navigated away from SERVERS, this is a no-op.
184 if (navController.currentDestination?.route == Routes.SERVERS) {
185 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
186 }
187 },
188 onAddProfile = { navController.navigate(Routes.serverEdit()) },
189 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
190 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
191 )
192 }
194 composable(
195 route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
196 arguments = listOf(
197 navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
198 navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
199 ),
200 ) { entry ->
201 ServerEditScreen(
202 container = container,
203 profileId = entry.arguments?.getString("id"),
204 sourceId = entry.arguments?.getString("dup"),
205 onDone = { navController.popBackStack() },
206 )
207 }
209 composable(Routes.SESSIONS) {
210 if (isTwoPane) {
211 TwoPaneSessionChat(
212 container = container,
213 onOpenFiles = { navController.navigate(Routes.FILES) },
214 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
215 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
216 onOpenSearch = { navController.navigate(Routes.SEARCH) },
217 onDisconnect = {
218 scope.launch { runCatchingCancellable { container.disconnect() } }
219 navController.popBackStack(Routes.SERVERS, inclusive = false)
220 },
221 onAddServer = { navController.navigate(Routes.serverEdit()) },
222 )
223 } else {
224 SessionListScreen(
225 container = container,
226 onOpenSession = { id ->
227 // Set the draft synchronously in-memory before navigating so the
228 // ChatScreen sees it on first composition. The async persistence
229 // to disk happens in the background via draftStore.set.
230 container.consumePendingShare()?.let { shareText ->
231 container.draftStore.setImmediate(id, shareText)
232 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
233 }
234 navController.navigate(Routes.chat(id))
235 },
236 onDisconnect = {
237 scope.launch { runCatchingCancellable { container.disconnect() } }
238 navController.popBackStack(Routes.SERVERS, inclusive = false)
239 },
240 onOpenFiles = { navController.navigate(Routes.FILES) },
241 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
242 onOpenSearch = { navController.navigate(Routes.SEARCH) },
243 onAddServer = { navController.navigate(Routes.serverEdit()) },
244 )
245 }
246 }
248 composable(
249 route = "${Routes.CHAT}/{sessionId}",
250 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
251 ) { entry ->
252 ChatScreen(
253 container = container,
254 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
255 onBack = { navController.popBackStack() },
256 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
257 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
258 )
259 }
261 composable(Routes.FILES) {
262 FileBrowserScreen(
263 container = container,
264 onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
265 onBack = { navController.popBackStack() },
266 )
267 }
269 composable(
270 route = "${Routes.FILE_VIEW}?path={path}&line={line}",
271 arguments = listOf(
272 navArgument("path") { type = NavType.StringType; defaultValue = "" },
273 navArgument("line") { type = NavType.IntType; defaultValue = -1 },
274 ),
275 ) { entry ->
276 val line = entry.arguments?.getInt("line") ?: -1
277 FileViewScreen(
278 container = container,
279 path = entry.arguments?.getString("path").orEmpty(),
280 initialLine = line.takeIf { it > 0 },
281 onBack = { navController.popBackStack() },
282 )
283 }
285 composable(Routes.SETTINGS) {
286 SettingsScreen(
287 container = container,
288 onBack = { navController.popBackStack() },
289 // Navigate to the server list as a new entry on top of Sessions so the
290 // user can back out to their conversations. Previously this popped back
291 // to the root SERVERS destination, which removed Sessions from the stack
292 // and left the user unable to return to their conversations without
293 // reconnecting. Pop only Settings, then push SERVERS; back from the
294 // server list lands on Sessions (the screen the user was on before
295 // Settings), and connecting pushes a fresh SESSIONS as usual.
296 onManageServers = {
297 navController.navigate(Routes.SERVERS) {
298 popUpTo(Routes.SETTINGS) { inclusive = true }
299 launchSingleTop = true
300 }
301 },
302 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
303 onOpenUsage = { navController.navigate(Routes.USAGE) },
304 onOpenMcp = { navController.navigate(Routes.MCP) },
305 )
306 }
308 composable(Routes.DIAGNOSTICS) {
309 DiagnosticsScreen(onBack = { navController.popBackStack() })
310 }
312 composable(Routes.USAGE) {
313 UsageScreen(container = container, onBack = { navController.popBackStack() })
314 }
316 composable(Routes.MCP) {
317 McpScreen(container = container, onBack = { navController.popBackStack() })
318 }
320 composable(Routes.SEARCH) {
321 GlobalSearchScreen(
322 container = container,
323 onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
324 onBack = { navController.popBackStack() },
325 )
326 }
327 }
328 }
329 }