git.iko.soy/opencode-android
on master help sign in
Improve UI/UX with markdown rendering, error surfacing, back navigation, persisted server URL, generating indicator, session titles, timestamps, pull-to-refresh, crash log viewer, and crossfade transitions
commite7dfe2032c44b15f1c3c82cea7401e8b1a635e28 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 09:56 (3 months ago) parent3a942c0
13 files changed+587−89
app/build.gradle.kts+4−2 app/src/main/java/soy/iko/opencode/MainActivity.kt+27−4 app/src/main/java/soy/iko/opencode/core/Core.kt+29−4 app/src/main/java/soy/iko/opencode/core/PureCoreFfi.kt+26−1 app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.kt+56 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+130−19 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+127−27 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+40−22 generated/soy/iko/opencode/core/Core.kt+23−3 gradle.properties+1 gradle/libs.versions.toml+4 shared/build.gradle.kts+2−2 shared/src/app.rs+118−5
app/build.gradle.kts+4−2
@@ -9,8 +9,8 @@ plugins {
99
1010android {
1111 namespace = "soy.iko.opencode"
12− compileSdk = 36
13− buildToolsVersion = "36.0.0"
12+ compileSdk = 37
13+ buildToolsVersion = "37.0.0"
1414
1515 defaultConfig {
1616 applicationId = "soy.iko.opencode"
@@ -77,6 +77,8 @@ dependencies {
7777 implementation(libs.androidx.navigation.compose)
7878
7979 implementation(libs.kotlinx.coroutines.android)
80+ implementation(libs.markdown.renderer.core)
81+ implementation(libs.markdown.renderer.m3)
8082
8183 implementation(libs.ktor.client.core)
8284 implementation(libs.ktor.client.okhttp)
app/src/main/java/soy/iko/opencode/MainActivity.kt+27−4
@@ -4,11 +4,17 @@ import android.os.Bundle
44import androidx.activity.ComponentActivity
55import androidx.activity.compose.setContent
66import androidx.activity.enableEdgeToEdge
7+import androidx.activity.compose.BackHandler
8+import androidx.compose.animation.AnimatedContent
9+import androidx.compose.animation.togetherWith
10+import androidx.compose.animation.fadeIn
11+import androidx.compose.animation.fadeOut
712import androidx.compose.runtime.Composable
813import androidx.compose.runtime.collectAsState
914import androidx.compose.runtime.getValue
1015import androidx.lifecycle.viewmodel.compose.viewModel
1116import soy.iko.opencode.core.Core
17+import soy.iko.opencode.core.Event
1218import soy.iko.opencode.core.Screen
1319import soy.iko.opencode.ui.screens.ChatScreen
1420import soy.iko.opencode.ui.screens.ConnectScreen
@@ -31,9 +37,26 @@ class MainActivity : ComponentActivity() {
3137fun App(core: Core = viewModel()) {
3238 val view by core.view.collectAsState()
3339
34− when (view.screen) {
35− Screen.CONNECT -> ConnectScreen(core)
36− Screen.SESSIONS -> SessionsScreen(core)
37− Screen.CHAT -> ChatScreen(core)
40+ // Map the system back button to logical in-app navigation so it never
41+ // silently exits the app from a sub-screen.
42+ BackHandler(enabled = view.screen != Screen.CONNECT) {
43+ when (view.screen) {
44+ Screen.CHAT -> core.update(Event.NavigateToSessions)
45+ Screen.SESSIONS -> core.update(Event.NavigateToConnect)
46+ Screen.CONNECT -> { /* no-op; CONNECT is the root */ }
47+ }
48+ }
49+
50+ // Crossfade between screens for a smoother navigation feel.
51+ AnimatedContent(
52+ targetState = view.screen,
53+ transitionSpec = { fadeIn() togetherWith fadeOut() },
54+ label = "screen-transition",
55+ ) { screen ->
56+ when (screen) {
57+ Screen.CONNECT -> ConnectScreen(core)
58+ Screen.SESSIONS -> SessionsScreen(core)
59+ Screen.CHAT -> ChatScreen(core)
60+ }
3861 }
3962}
app/src/main/java/soy/iko/opencode/core/Core.kt+29−4
@@ -1,5 +1,7 @@
11package soy.iko.opencode.core
22
3+import android.app.Application
4+import androidx.lifecycle.AndroidViewModel
35import androidx.lifecycle.viewModelScope
46import kotlinx.coroutines.flow.MutableStateFlow
57import kotlinx.coroutines.flow.StateFlow
@@ -23,15 +25,23 @@ import soy.iko.opencode.CrashLogger
2325 * Crash logs from [CrashLogger] are forwarded to the core as
2426 * [Event.CrashLog] events so they appear in the ViewModel.
2527 */
26−class Core : androidx.lifecycle.ViewModel() {
28+class Core(application: Application) : AndroidViewModel(application) {
2729 private val ffi: CoreFfi = CoreFfi.create()
2830 private val httpClient = HttpClient()
2931 private val sseClient = SseClient()
3032
33+ private val prefs = application.getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE)
34+
3135 private val _view = MutableStateFlow(getInitialView())
3236 val view: StateFlow<ViewModel> = _view.asStateFlow()
3337
3438 init {
39+ // Restore the persisted server URL before the first connect so the
40+ // user doesn't have to re-enter it on every launch.
41+ val savedUrl = prefs.getString(KEY_SERVER_URL, null)
42+ if (savedUrl != null) {
43+ update(Event.ServerUrlChanged(savedUrl))
44+ }
3545 update(Event.Start)
3646
3747 viewModelScope.launch {
@@ -54,7 +64,7 @@ class Core : androidx.lifecycle.ViewModel() {
5464 private fun processEffect(request: Request) {
5565 when (val effect = request.effect) {
5666 is Effect.Render -> {
57− _view.value = ViewModel.bincodeDeserialize(ffi.view())
67+ refreshView()
5868 }
5969 is Effect.Http -> {
6070 handleHttpEffect(effect.value, request.id)
@@ -70,7 +80,7 @@ class Core : androidx.lifecycle.ViewModel() {
7080 for (req in requests) {
7181 processEffect(req)
7282 }
73− _view.value = ViewModel.bincodeDeserialize(ffi.view())
83+ refreshView()
7484
7585 val currentView = _view.value
7686 if (currentView.connected && currentView.currentSessionId != null) {
@@ -79,6 +89,13 @@ class Core : androidx.lifecycle.ViewModel() {
7989 }
8090 }
8191
92+ private fun refreshView() {
93+ val newView = ViewModel.bincodeDeserialize(ffi.view())
94+ _view.value = newView
95+ // Persist the server URL whenever it changes so it survives relaunch.
96+ prefs.edit().putString(KEY_SERVER_URL, newView.serverUrl).apply()
97+ }
98+
8299 /**
83100 * Forward any persisted crash reports to the core so they appear in the
84101 * ViewModel's crash-log summary. This runs once on init.
@@ -99,9 +116,10 @@ class Core : androidx.lifecycle.ViewModel() {
99116 return try {
100117 ViewModel.bincodeDeserialize(ffi.view())
101118 } catch (e: Throwable) {
119+ val defaultUrl = prefs.getString(KEY_SERVER_URL, "http://localhost:4096") ?: "http://localhost:4096"
102120 ViewModel(
103121 screen = Screen.CONNECT,
104− serverUrl = "http://localhost:4096",
122+ serverUrl = defaultUrl,
105123 username = "",
106124 password = "",
107125 authRequired = false,
@@ -110,8 +128,10 @@ class Core : androidx.lifecycle.ViewModel() {
110128 error = null,
111129 sessions = emptyList(),
112130 currentSessionId = null,
131+ currentSessionTitle = "",
113132 messages = emptyList(),
114133 draftMessage = "",
134+ generating = false,
115135 crashLogCount = 0u,
116136 latestCrashLog = null,
117137 )
@@ -121,4 +141,9 @@ class Core : androidx.lifecycle.ViewModel() {
121141 override fun onCleared() {
122142 sseClient.disconnect()
123143 }
144+
145+ companion object {
146+ private const val PREFS_NAME = "opencode_prefs"
147+ private const val KEY_SERVER_URL = "server_url"
148+ }
124149}
app/src/main/java/soy/iko/opencode/core/PureCoreFfi.kt+26−1
@@ -45,8 +45,10 @@ internal data class PureModel(
4545 var error: String? = null,
4646 var sessions: MutableList<PureSession> = mutableListOf(),
4747 var currentSessionId: String? = null,
48+ var currentSessionTitle: String = "",
4849 var messages: MutableList<PureMessage> = mutableListOf(),
4950 var draftMessage: String = "",
51+ var generating: Boolean = false,
5052 var crashLogs: MutableList<String> = mutableListOf(),
5153 var pendingRequests: MutableMap<UInt, PendingRequest> = mutableMapOf(),
5254 var nextId: UInt = 0u,
@@ -55,7 +57,7 @@ internal data class PureModel(
5557 val requests = mutableListOf<Request>()
5658 when (event) {
5759 is Event.Start -> {
58− serverUrl = "http://localhost:4096"
60+ // Don't reset serverUrl — the shell restores the persisted value.
5961 }
6062 is Event.ServerUrlChanged -> {
6163 serverUrl = event.value
@@ -87,6 +89,7 @@ internal data class PureModel(
8789 }
8890 is Event.SelectSession -> {
8991 currentSessionId = event.value
92+ currentSessionTitle = sessions.firstOrNull { it.id == event.value }?.title ?: ""
9093 messages.clear()
9194 val id = nextId++
9295 pendingRequests[id] = PendingRequest.LoadMessages(event.value)
@@ -104,6 +107,7 @@ internal data class PureModel(
104107 }
105108 is Event.LoadMessages -> {
106109 loading = true
110+ generating = false
107111 val id = nextId++
108112 pendingRequests[id] = PendingRequest.LoadMessages(event.value)
109113 requests.add(
@@ -113,6 +117,7 @@ internal data class PureModel(
113117 is Event.SendMessage -> {
114118 val sessionId = currentSessionId ?: return Requests(requests)
115119 draftMessage = ""
120+ generating = true
116121 val body = """{"sessionID":"$sessionId","parts":[{"type":"text","text":"${event.value}"}]}"""
117122 .toByteArray()
118123 val id = nextId++
@@ -139,11 +144,23 @@ internal data class PureModel(
139144 }
140145 is Event.NavigateToChat -> {
141146 currentSessionId = event.value
147+ currentSessionTitle = sessions.firstOrNull { it.id == event.value }?.title ?: ""
142148 messages.clear()
143149 }
144150 is Event.NavigateToSessions -> {
145151 currentSessionId = null
152+ currentSessionTitle = ""
146153 messages.clear()
154+ generating = false
155+ }
156+ is Event.NavigateToConnect -> {
157+ currentSessionId = null
158+ currentSessionTitle = ""
159+ messages.clear()
160+ generating = false
161+ connected = false
162+ authRequired = false
163+ loading = false
147164 }
148165 is Event.DismissError -> {
149166 error = null
@@ -210,7 +227,11 @@ internal data class PureModel(
210227 val body = result.value.body.content.decodeToString()
211228 val sid = extractJsonString(body, "id") ?: ""
212229 if (sid.isNotEmpty()) {
230+ val title = extractJsonString(body, "title")?.takeIf { it.isNotEmpty() }
231+ ?: "New session"
213232 currentSessionId = sid
233+ currentSessionTitle = title
234+ sessions.add(0, PureSession(sid, title))
214235 messages.clear()
215236 val newId = nextId++
216237 pendingRequests[newId] = PendingRequest.LoadMessages(sid)
@@ -226,6 +247,7 @@ internal data class PureModel(
226247 }
227248 is PendingRequest.LoadMessages -> {
228249 loading = false
250+ generating = false
229251 when (result) {
230252 is HttpResult.Ok -> {
231253 val body = result.value.body.content.decodeToString()
@@ -247,6 +269,7 @@ internal data class PureModel(
247269 )
248270 }
249271 is HttpResult.Err -> {
272+ generating = false
250273 error = "Failed to send message: ${result.value}"
251274 }
252275 }
@@ -272,8 +295,10 @@ internal data class PureModel(
272295 error = error,
273296 sessions = sessions.map { SessionView(it.id, it.title) },
274297 currentSessionId = currentSessionId,
298+ currentSessionTitle = currentSessionTitle,
275299 messages = messages.map { MessageView(it.id, it.role, it.text, it.time) },
276300 draftMessage = draftMessage,
301+ generating = generating,
277302 crashLogCount = crashLogs.size.toUInt(),
278303 latestCrashLog = crashLogs.lastOrNull(),
279304 )
app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.ktnew file+56
@@ -0,0 +1,56 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.material3.MaterialTheme
4+import androidx.compose.material3.Snackbar
5+import androidx.compose.material3.SnackbarDuration
6+import androidx.compose.material3.SnackbarHost
7+import androidx.compose.material3.SnackbarHostState
8+import androidx.compose.material3.SnackbarResult
9+import androidx.compose.runtime.Composable
10+import androidx.compose.runtime.LaunchedEffect
11+import androidx.compose.runtime.collectAsState
12+import androidx.compose.runtime.getValue
13+import androidx.compose.runtime.remember
14+import soy.iko.opencode.core.Core
15+import soy.iko.opencode.core.Event
16+
17+/**
18+ * A [SnackbarHostState] that observes [ViewModel.error] and surfaces it to
19+ * the user. Offers a "Dismiss" action that sends [Event.DismissError] to
20+ * clear the error in the core so it doesn't re-show on recomposition.
21+ *
22+ * Used by every screen so error messages are never silently dropped.
23+ */
24+@Composable
25+fun ErrorHost(core: Core): SnackbarHostState {
26+ val view by core.view.collectAsState()
27+ val snackbarHostState = remember { SnackbarHostState() }
28+
29+ LaunchedEffect(view.error) {
30+ val message = view.error
31+ if (!message.isNullOrEmpty()) {
32+ val result = snackbarHostState.showSnackbar(
33+ message = message,
34+ actionLabel = "Dismiss",
35+ duration = SnackbarDuration.Long,
36+ )
37+ if (result == SnackbarResult.ActionPerformed) {
38+ core.update(Event.DismissError)
39+ }
40+ }
41+ }
42+
43+ return snackbarHostState
44+}
45+
46+@Composable
47+fun ErrorSnackbarHost(snackbarHostState: SnackbarHostState) {
48+ SnackbarHost(snackbarHostState) { data ->
49+ Snackbar(
50+ containerColor = MaterialTheme.colorScheme.errorContainer,
51+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
52+ actionContentColor = MaterialTheme.colorScheme.onErrorContainer,
53+ snackbarData = data,
54+ )
55+ }
56+}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+130−19
@@ -1,8 +1,10 @@
11package soy.iko.opencode.ui.screens
22
3+import androidx.compose.animation.AnimatedVisibility
34import androidx.compose.foundation.layout.Arrangement
45import androidx.compose.foundation.layout.Box
56import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.PaddingValues
68import androidx.compose.foundation.layout.Row
79import androidx.compose.foundation.layout.Spacer
810import androidx.compose.foundation.layout.fillMaxSize
@@ -31,16 +33,26 @@ import androidx.compose.material3.TopAppBar
3133import androidx.compose.runtime.Composable
3234import androidx.compose.runtime.LaunchedEffect
3335import androidx.compose.runtime.collectAsState
36+import androidx.compose.runtime.derivedStateOf
3437import androidx.compose.runtime.getValue
3538import androidx.compose.runtime.mutableStateOf
3639import androidx.compose.runtime.remember
3740import androidx.compose.runtime.setValue
3841import androidx.compose.ui.Alignment
3942import androidx.compose.ui.Modifier
40−import androidx.compose.ui.draw.clip
43+import androidx.compose.ui.platform.LocalConfiguration
44+import androidx.compose.ui.text.font.FontStyle
45+import androidx.compose.ui.text.style.TextOverflow
4146import androidx.compose.ui.unit.dp
47+import com.mikepenz.markdown.m3.Markdown
4248import soy.iko.opencode.core.Core
4349import soy.iko.opencode.core.Event
50+import soy.iko.opencode.core.MessageView
51+import soy.iko.opencode.ui.components.ErrorHost
52+import soy.iko.opencode.ui.components.ErrorSnackbarHost
53+import java.text.SimpleDateFormat
54+import java.util.Date
55+import java.util.Locale
4456
4557@OptIn(ExperimentalMaterial3Api::class)
4658@Composable
@@ -49,8 +61,18 @@ fun ChatScreen(core: Core) {
4961 var inputText by remember { mutableStateOf("") }
5062 val listState = rememberLazyListState()
5163
64+ // Track whether the user is parked at the bottom of the list. We only
65+ // auto-scroll when they are, so reading history isn't yanked away as
66+ // new messages stream in.
67+ val isAtBottom by remember {
68+ derivedStateOf {
69+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
70+ lastVisible >= view.messages.size - 2
71+ }
72+ }
73+
5274 LaunchedEffect(view.messages.size) {
53− if (view.messages.isNotEmpty()) {
75+ if (view.messages.isNotEmpty() && (isAtBottom || view.messages.size == 1)) {
5476 listState.animateScrollToItem(view.messages.size - 1)
5577 }
5678 }
@@ -59,10 +81,23 @@ fun ChatScreen(core: Core) {
5981 topBar = {
6082 TopAppBar(
6183 title = {
62− Text(
63− view.currentSessionId?.take(8)?.plus("…") ?: "Chat",
64− maxLines = 1,
65− )
84+ Column {
85+ Text(
86+ text = view.currentSessionTitle.ifEmpty {
87+ view.currentSessionId?.take(8)?.plus("…") ?: "Chat"
88+ },
89+ maxLines = 1,
90+ overflow = TextOverflow.Ellipsis,
91+ )
92+ if (view.currentSessionTitle.isNotEmpty() && view.currentSessionId != null) {
93+ Text(
94+ text = view.currentSessionId!!.take(8),
95+ style = MaterialTheme.typography.labelSmall,
96+ color = MaterialTheme.colorScheme.onSurfaceVariant,
97+ maxLines = 1,
98+ )
99+ }
100+ }
66101 },
67102 navigationIcon = {
68103 IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
@@ -84,6 +119,7 @@ fun ChatScreen(core: Core) {
84119 enabled = !view.loading,
85120 )
86121 },
122+ snackbarHost = { ErrorSnackbarHost(ErrorHost(core)) },
87123 ) { padding ->
88124 Box(
89125 modifier = Modifier
@@ -111,11 +147,18 @@ fun ChatScreen(core: Core) {
111147 state = listState,
112148 modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
113149 verticalArrangement = Arrangement.spacedBy(8.dp),
114− contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
150+ contentPadding = PaddingValues(vertical = 12.dp),
115151 ) {
116152 items(view.messages, key = { it.id }) { message ->
117153 MessageBubble(message)
118154 }
155+ // "Thinking" indicator shown after the user sends, while
156+ // the assistant reply hasn't arrived yet.
157+ item(key = "__generating__") {
158+ AnimatedVisibility(visible = view.generating) {
159+ GeneratingIndicator()
160+ }
161+ }
119162 }
120163 }
121164 }
@@ -123,8 +166,9 @@ fun ChatScreen(core: Core) {
123166}
124167
125168@Composable
126−private fun MessageBubble(message: soy.iko.opencode.core.MessageView) {
169+private fun MessageBubble(message: MessageView) {
127170 val isUser = message.role == "user"
171+ val maxWidth = LocalConfiguration.current.screenWidthDp.dp * 0.82f
128172 Row(
129173 modifier = Modifier.fillMaxWidth(),
130174 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
@@ -146,22 +190,86 @@ private fun MessageBubble(message: soy.iko.opencode.core.MessageView) {
146190 bottomStart = if (isUser) 16.dp else 4.dp,
147191 bottomEnd = if (isUser) 4.dp else 16.dp,
148192 ),
149− modifier = Modifier.widthIn(max = 280.dp),
193+ modifier = Modifier.widthIn(max = maxWidth),
150194 ) {
151195 Column(
152196 modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
153197 ) {
154198 if (!isUser) {
199+ Row(
200+ modifier = Modifier.fillMaxWidth(),
201+ horizontalArrangement = Arrangement.SpaceBetween,
202+ verticalAlignment = Alignment.CenterVertically,
203+ ) {
204+ Text(
205+ text = message.role.replaceFirstChar { it.uppercase() },
206+ style = MaterialTheme.typography.labelSmall,
207+ color = MaterialTheme.colorScheme.primary,
208+ )
209+ if (message.time > 0uL) {
210+ Text(
211+ text = formatTimestamp(message.time),
212+ style = MaterialTheme.typography.labelSmall,
213+ color = MaterialTheme.colorScheme.onSurfaceVariant,
214+ )
215+ }
216+ }
217+ Spacer(Modifier.height(2.dp))
218+ }
219+ if (message.text.isBlank()) {
155220 Text(
156− text = message.role.replaceFirstChar { it.uppercase() },
157− style = MaterialTheme.typography.labelSmall,
158− color = MaterialTheme.colorScheme.primary,
221+ text = "(no content)",
222+ style = MaterialTheme.typography.bodyMedium,
223+ fontStyle = FontStyle.Italic,
224+ )
225+ } else if (isUser) {
226+ Text(
227+ text = message.text,
228+ style = MaterialTheme.typography.bodyMedium,
229+ )
230+ } else {
231+ // Assistant replies often contain markdown (code blocks,
232+ // lists, etc.) — render them properly.
233+ Markdown(
234+ content = message.text,
235+ modifier = Modifier.fillMaxWidth(),
159236 )
160− Spacer(Modifier.height(2.dp))
161237 }
238+ }
239+ }
240+ }
241+}
242+
243+@Composable
244+private fun GeneratingIndicator() {
245+ Row(
246+ horizontalArrangement = Arrangement.Start,
247+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
248+ ) {
249+ Surface(
250+ color = MaterialTheme.colorScheme.surfaceVariant,
251+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
252+ shape = RoundedCornerShape(
253+ topStart = 16.dp,
254+ topEnd = 16.dp,
255+ bottomStart = 4.dp,
256+ bottomEnd = 16.dp,
257+ ),
258+ modifier = Modifier.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.82f),
259+ ) {
260+ Row(
261+ verticalAlignment = Alignment.CenterVertically,
262+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
263+ ) {
264+ CircularProgressIndicator(
265+ modifier = Modifier.size(14.dp),
266+ strokeWidth = 2.dp,
267+ )
268+ Spacer(Modifier.size(8.dp))
162269 Text(
163− text = message.text.ifBlank { "(no content)" },
270+ text = "Generating…",
164271 style = MaterialTheme.typography.bodyMedium,
272+ fontStyle = FontStyle.Italic,
165273 )
166274 }
167275 }
@@ -193,6 +301,8 @@ private fun ChatInputBar(
193301 maxLines = 4,
194302 )
195303 Spacer(Modifier.size(8.dp))
304+ // Rely on the standard disabled styling instead of overriding
305+ // the tint manually.
196306 IconButton(
197307 onClick = onSend,
198308 enabled = enabled && text.isNotBlank(),
@@ -200,13 +310,14 @@ private fun ChatInputBar(
200310 Icon(
201311 Icons.AutoMirrored.Filled.Send,
202312 contentDescription = "Send",
203− tint = if (text.isNotBlank()) {
204− MaterialTheme.colorScheme.primary
205− } else {
206− MaterialTheme.colorScheme.onSurfaceVariant
207− },
208313 )
209314 }
210315 }
211316 }
212317}
318+
319+private fun formatTimestamp(epochSeconds: ULong): String {
320+ val date = Date(epochSeconds.toLong() * 1000)
321+ val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
322+ return formatter.format(date)
323+}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+127−27
@@ -3,30 +3,36 @@ package soy.iko.opencode.ui.screens
33import androidx.compose.animation.AnimatedVisibility
44import androidx.compose.foundation.layout.Arrangement
55import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
67import androidx.compose.foundation.layout.Spacer
78import androidx.compose.foundation.layout.fillMaxSize
89import androidx.compose.foundation.layout.fillMaxWidth
910import androidx.compose.foundation.layout.height
1011import androidx.compose.foundation.layout.padding
1112import androidx.compose.foundation.layout.size
13+import androidx.compose.foundation.rememberScrollState
14+import androidx.compose.foundation.text.KeyboardActions
1215import androidx.compose.foundation.text.KeyboardOptions
16+import androidx.compose.foundation.verticalScroll
1317import androidx.compose.material.icons.Icons
1418import androidx.compose.material.icons.filled.Dns
1519import androidx.compose.material.icons.filled.Lock
20+import androidx.compose.material.icons.filled.Visibility
21+import androidx.compose.material.icons.filled.VisibilityOff
22+import androidx.compose.material.icons.filled.Warning
23+import androidx.compose.material3.AlertDialog
1624import androidx.compose.material3.Button
1725import androidx.compose.material3.CircularProgressIndicator
1826import androidx.compose.material3.ExperimentalMaterial3Api
1927import androidx.compose.material3.Icon
28+import androidx.compose.material3.IconButton
2029import androidx.compose.material3.MaterialTheme
2130import androidx.compose.material3.OutlinedTextField
2231import androidx.compose.material3.Scaffold
23−import androidx.compose.material3.SnackbarHost
24−import androidx.compose.material3.SnackbarHostState
2532import androidx.compose.material3.Text
2633import androidx.compose.material3.TextButton
2734import androidx.compose.material3.TopAppBar
2835import androidx.compose.runtime.Composable
29−import androidx.compose.runtime.LaunchedEffect
3036import androidx.compose.runtime.collectAsState
3137import androidx.compose.runtime.getValue
3238import androidx.compose.runtime.mutableStateOf
@@ -37,9 +43,14 @@ import androidx.compose.ui.Modifier
3743import androidx.compose.ui.text.input.ImeAction
3844import androidx.compose.ui.text.input.KeyboardType
3945import androidx.compose.ui.text.input.PasswordVisualTransformation
46+import androidx.compose.ui.text.input.VisualTransformation
4047import androidx.compose.ui.unit.dp
48+import soy.iko.opencode.CrashLogger
4149import soy.iko.opencode.core.Core
4250import soy.iko.opencode.core.Event
51+import soy.iko.opencode.ui.components.ErrorHost
52+import soy.iko.opencode.ui.components.ErrorSnackbarHost
53+import java.io.File
4354
4455@OptIn(ExperimentalMaterial3Api::class)
4556@Composable
@@ -48,19 +59,24 @@ fun ConnectScreen(core: Core) {
4859 var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
4960 var username by remember(view.username) { mutableStateOf(view.username) }
5061 var password by remember(view.password) { mutableStateOf(view.password) }
51− val snackbarHostState = remember { SnackbarHostState() }
62+ var passwordVisible by remember { mutableStateOf(false) }
63+ var showCrashLogs by remember { mutableStateOf(false) }
64+ val snackbarHostState = ErrorHost(core)
5265
53− LaunchedEffect(view.error) {
54− view.error?.let {
55− snackbarHostState.showSnackbar(it)
66+ fun submit() {
67+ core.update(Event.ServerUrlChanged(url))
68+ if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
69+ core.update(Event.UsernameChanged(username))
70+ core.update(Event.PasswordChanged(password))
5671 }
72+ core.update(Event.Connect)
5773 }
5874
5975 Scaffold(
6076 topBar = {
6177 TopAppBar(title = { Text("Opencode") })
6278 },
63− snackbarHost = { SnackbarHost(snackbarHostState) },
79+ snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
6480 ) { padding ->
6581 Column(
6682 horizontalAlignment = Alignment.CenterHorizontally,
@@ -68,7 +84,8 @@ fun ConnectScreen(core: Core) {
6884 modifier = Modifier
6985 .fillMaxSize()
7086 .padding(padding)
71− .padding(24.dp),
87+ .padding(24.dp)
88+ .verticalScroll(rememberScrollState()),
7289 ) {
7390 Icon(
7491 imageVector = Icons.Default.Dns,
@@ -91,7 +108,8 @@ fun ConnectScreen(core: Core) {
91108 keyboardType = KeyboardType.Uri,
92109 imeAction = ImeAction.Go,
93110 ),
94− modifier = Modifier.padding(horizontal = 16.dp),
111+ keyboardActions = KeyboardActions(onGo = { submit() }),
112+ modifier = Modifier.fillMaxWidth(),
95113 )
96114
97115 AnimatedVisibility(visible = view.authRequired) {
@@ -113,7 +131,10 @@ fun ConnectScreen(core: Core) {
113131 leadingIcon = {
114132 Icon(Icons.Default.Lock, contentDescription = null)
115133 },
116− modifier = Modifier.padding(horizontal = 16.dp),
134+ keyboardOptions = KeyboardOptions(
135+ imeAction = ImeAction.Next,
136+ ),
137+ modifier = Modifier.fillMaxWidth(),
117138 )
118139 Spacer(Modifier.height(8.dp))
119140 OutlinedTextField(
@@ -121,12 +142,33 @@ fun ConnectScreen(core: Core) {
121142 onValueChange = { password = it },
122143 label = { Text("Password") },
123144 singleLine = true,
124− visualTransformation = PasswordVisualTransformation(),
145+ visualTransformation = if (passwordVisible) {
146+ VisualTransformation.None
147+ } else {
148+ PasswordVisualTransformation()
149+ },
150+ trailingIcon = {
151+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
152+ Icon(
153+ if (passwordVisible) {
154+ Icons.Default.VisibilityOff
155+ } else {
156+ Icons.Default.Visibility
157+ },
158+ contentDescription = if (passwordVisible) {
159+ "Hide password"
160+ } else {
161+ "Show password"
162+ },
163+ )
164+ }
165+ },
125166 keyboardOptions = KeyboardOptions(
126167 keyboardType = KeyboardType.Password,
127168 imeAction = ImeAction.Go,
128169 ),
129− modifier = Modifier.padding(horizontal = 16.dp),
170+ keyboardActions = KeyboardActions(onGo = { submit() }),
171+ modifier = Modifier.fillMaxWidth(),
130172 )
131173 Spacer(Modifier.height(4.dp))
132174 TextButton(onClick = { core.update(Event.CancelAuth) }) {
@@ -137,15 +179,9 @@ fun ConnectScreen(core: Core) {
137179
138180 Spacer(Modifier.height(16.dp))
139181 Button(
140− onClick = {
141− core.update(Event.ServerUrlChanged(url))
142− if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
143− core.update(Event.UsernameChanged(username))
144− core.update(Event.PasswordChanged(password))
145− }
146− core.update(Event.Connect)
147− },
182+ onClick = { submit() },
148183 enabled = !view.loading,
184+ modifier = Modifier.fillMaxWidth(),
149185 ) {
150186 if (view.loading) {
151187 CircularProgressIndicator(
@@ -158,14 +194,78 @@ fun ConnectScreen(core: Core) {
158194 }
159195 }
160196
161− if (view.crashLogCount > 0u) {
197+ val crashCount = CrashLogger.getReports().size
198+ if (crashCount > 0) {
162199 Spacer(Modifier.height(16.dp))
163− Text(
164− text = "${view.crashLogCount} crash report(s) recorded",
165− style = MaterialTheme.typography.labelSmall,
166− color = MaterialTheme.colorScheme.onSurfaceVariant,
167− )
200+ Row(verticalAlignment = Alignment.CenterVertically) {
201+ Icon(
202+ Icons.Default.Warning,
203+ contentDescription = null,
204+ tint = MaterialTheme.colorScheme.error,
205+ modifier = Modifier.size(16.dp),
206+ )
207+ Spacer(Modifier.size(6.dp))
208+ TextButton(onClick = { showCrashLogs = true }) {
209+ Text(
210+ text = "$crashCount crash report(s) — view",
211+ style = MaterialTheme.typography.labelSmall,
212+ )
213+ }
214+ }
168215 }
169216 }
170217 }
218+
219+ if (showCrashLogs) {
220+ CrashLogDialog(
221+ reports = CrashLogger.getReports(),
222+ onDismiss = { showCrashLogs = false },
223+ onClear = {
224+ CrashLogger.clearReports()
225+ showCrashLogs = false
226+ },
227+ )
228+ }
229+}
230+
231+@Composable
232+private fun CrashLogDialog(
233+ reports: List<File>,
234+ onDismiss: () -> Unit,
235+ onClear: () -> Unit,
236+) {
237+ AlertDialog(
238+ onDismissRequest = onDismiss,
239+ title = { Text("Crash Reports") },
240+ text = {
241+ Column(
242+ modifier = Modifier
243+ .fillMaxWidth()
244+ .height(360.dp)
245+ .verticalScroll(rememberScrollState()),
246+ ) {
247+ reports.forEachIndexed { index, file ->
248+ Text(
249+ text = file.name,
250+ style = MaterialTheme.typography.labelMedium,
251+ color = MaterialTheme.colorScheme.primary,
252+ )
253+ Spacer(Modifier.height(4.dp))
254+ Text(
255+ text = file.readText(),
256+ style = MaterialTheme.typography.bodySmall,
257+ )
258+ if (index < reports.lastIndex) {
259+ Spacer(Modifier.height(12.dp))
260+ }
261+ }
262+ }
263+ },
264+ confirmButton = {
265+ TextButton(onClick = onClear) { Text("Clear all") }
266+ },
267+ dismissButton = {
268+ TextButton(onClick = onDismiss) { Text("Close") }
269+ },
270+ )
171271}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+40−22
@@ -1,6 +1,7 @@
11package soy.iko.opencode.ui.screens
22
33import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Arrangement
45import androidx.compose.foundation.layout.Box
56import androidx.compose.foundation.layout.Column
67import androidx.compose.foundation.layout.fillMaxSize
@@ -8,18 +9,21 @@ import androidx.compose.foundation.layout.padding
89import androidx.compose.foundation.lazy.LazyColumn
910import androidx.compose.foundation.lazy.items
1011import androidx.compose.material.icons.Icons
12+import androidx.compose.material.icons.automirrored.filled.ArrowBack
1113import androidx.compose.material.icons.filled.Add
1214import androidx.compose.material.icons.filled.ChatBubble
15+import androidx.compose.material.icons.filled.Settings
1316import androidx.compose.material3.CircularProgressIndicator
1417import androidx.compose.material3.ExperimentalMaterial3Api
15−import androidx.compose.material3.FloatingActionButton
1618import androidx.compose.material3.Icon
1719import androidx.compose.material3.IconButton
1820import androidx.compose.material3.ListItem
1921import androidx.compose.material3.MaterialTheme
2022import androidx.compose.material3.Scaffold
23+import androidx.compose.material3.SnackbarHost
2124import androidx.compose.material3.Text
2225import androidx.compose.material3.TopAppBar
26+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
2327import androidx.compose.runtime.Composable
2428import androidx.compose.runtime.collectAsState
2529import androidx.compose.runtime.getValue
@@ -28,28 +32,35 @@ import androidx.compose.ui.Modifier
2832import androidx.compose.ui.unit.dp
2933import soy.iko.opencode.core.Core
3034import soy.iko.opencode.core.Event
35+import soy.iko.opencode.ui.components.ErrorHost
36+import soy.iko.opencode.ui.components.ErrorSnackbarHost
3137
3238@OptIn(ExperimentalMaterial3Api::class)
3339@Composable
3440fun SessionsScreen(core: Core) {
3541 val view by core.view.collectAsState()
42+ val snackbarHostState = ErrorHost(core)
3643
3744 Scaffold(
3845 topBar = {
3946 TopAppBar(
4047 title = { Text("Sessions") },
48+ navigationIcon = {
49+ IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
50+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Change server")
51+ }
52+ },
4153 actions = {
4254 IconButton(onClick = { core.update(Event.CreateSession) }) {
4355 Icon(Icons.Default.Add, contentDescription = "New session")
4456 }
57+ IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
58+ Icon(Icons.Default.Settings, contentDescription = "Settings")
59+ }
4560 },
4661 )
4762 },
48− floatingActionButton = {
49− FloatingActionButton(onClick = { core.update(Event.CreateSession) }) {
50− Icon(Icons.Default.Add, contentDescription = "New session")
51− }
52− },
63+ snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
5364 ) { padding ->
5465 Box(
5566 modifier = Modifier
@@ -63,6 +74,7 @@ fun SessionsScreen(core: Core) {
6374 } else if (view.sessions.isEmpty()) {
6475 Column(
6576 horizontalAlignment = Alignment.CenterHorizontally,
77+ verticalArrangement = Arrangement.Center,
6678 modifier = Modifier
6779 .fillMaxSize()
6880 .padding(24.dp),
@@ -74,24 +86,30 @@ fun SessionsScreen(core: Core) {
7486 )
7587 }
7688 } else {
77− LazyColumn {
78− items(view.sessions, key = { it.id }) { session ->
79− ListItem(
80− headlineContent = { Text(session.title.ifEmpty { "Untitled" }) },
81− supportingContent = { Text(session.id.take(8)) },
82− leadingContent = {
83− Icon(
84− Icons.Default.ChatBubble,
85− contentDescription = null,
86− tint = MaterialTheme.colorScheme.primary,
87− )
88− },
89− modifier = Modifier
90− .padding(horizontal = 0.dp)
91− .clickable {
89+ PullToRefreshBox(
90+ isRefreshing = view.loading,
91+ onRefresh = { core.update(Event.LoadSessions) },
92+ modifier = Modifier.fillMaxSize(),
93+ ) {
94+ LazyColumn {
95+ items(view.sessions, key = { it.id }) { session ->
96+ ListItem(
97+ headlineContent = {
98+ Text(session.title.ifEmpty { "Untitled" })
99+ },
100+ supportingContent = { Text(session.id.take(8)) },
101+ leadingContent = {
102+ Icon(
103+ Icons.Default.ChatBubble,
104+ contentDescription = null,
105+ tint = MaterialTheme.colorScheme.primary,
106+ )
107+ },
108+ modifier = Modifier.clickable {
92109 core.update(Event.SelectSession(session.id))
93110 },
94− )
111+ )
112+ }
95113 }
96114 }
97115 }
generated/soy/iko/opencode/core/Core.kt+23−3
@@ -363,13 +363,25 @@ sealed interface Event {
363363 }
364364 }
365365
366− data object DismissError: Event {
366+ data object NavigateToConnect: Event {
367367 override fun serialize(serializer: Serializer) {
368368 serializer.increase_container_depth()
369369 serializer.serialize_variant_index(14)
370370 serializer.decrease_container_depth()
371371 }
372372
373+ fun deserialize(deserializer: Deserializer): NavigateToConnect {
374+ return NavigateToConnect
375+ }
376+ }
377+
378+ data object DismissError: Event {
379+ override fun serialize(serializer: Serializer) {
380+ serializer.increase_container_depth()
381+ serializer.serialize_variant_index(15)
382+ serializer.decrease_container_depth()
383+ }
384+
373385 fun deserialize(deserializer: Deserializer): DismissError {
374386 return DismissError
375387 }
@@ -394,7 +406,8 @@ sealed interface Event {
394406 11 -> EventReceived.deserialize(deserializer)
395407 12 -> NavigateToChat.deserialize(deserializer)
396408 13 -> NavigateToSessions.deserialize(deserializer)
397− 14 -> DismissError.deserialize(deserializer)
409+ 14 -> NavigateToConnect.deserialize(deserializer)
410+ 15 -> DismissError.deserialize(deserializer)
398411 else -> throw DeserializationError("Unknown variant index for Event: $index")
399412 }
400413 }
@@ -1054,8 +1067,11 @@ data class ViewModel(
10541067 val error: String? = null,
10551068 val sessions: List<soy.iko.opencode.core.SessionView>,
10561069 val currentSessionId: String? = null,
1070+ val currentSessionTitle: String,
10571071 val messages: List<soy.iko.opencode.core.MessageView>,
10581072 val draftMessage: String,
1073+ /// True while waiting for the assistant to reply after the user sends.
1074+ val generating: Boolean,
10591075 val crashLogCount: UInt,
10601076 val latestCrashLog: String? = null,
10611077) {
@@ -1077,10 +1093,12 @@ data class ViewModel(
10771093 currentSessionId.serializeOptionOf(serializer) {
10781094 serializer.serialize_str(it)
10791095 }
1096+ serializer.serialize_str(currentSessionTitle)
10801097 messages.serialize(serializer) {
10811098 it.serialize(serializer)
10821099 }
10831100 serializer.serialize_str(draftMessage)
1101+ serializer.serialize_bool(generating)
10841102 serializer.serialize_u32(crashLogCount)
10851103 latestCrashLog.serializeOptionOf(serializer) {
10861104 serializer.serialize_str(it)
@@ -1116,18 +1134,20 @@ data class ViewModel(
11161134 deserializer.deserializeOptionOf {
11171135 deserializer.deserialize_str()
11181136 }
1137+ val currentSessionTitle = deserializer.deserialize_str()
11191138 val messages =
11201139 deserializer.deserializeListOf {
11211140 soy.iko.opencode.core.MessageView.deserialize(deserializer)
11221141 }
11231142 val draftMessage = deserializer.deserialize_str()
1143+ val generating = deserializer.deserialize_bool()
11241144 val crashLogCount = deserializer.deserialize_u32()
11251145 val latestCrashLog =
11261146 deserializer.deserializeOptionOf {
11271147 deserializer.deserialize_str()
11281148 }
11291149 deserializer.decrease_container_depth()
1130− return ViewModel(screen, serverUrl, username, password, authRequired, connected, loading, error, sessions, currentSessionId, messages, draftMessage, crashLogCount, latestCrashLog)
1150+ return ViewModel(screen, serverUrl, username, password, authRequired, connected, loading, error, sessions, currentSessionId, currentSessionTitle, messages, draftMessage, generating, crashLogCount, latestCrashLog)
11311151 }
11321152
11331153 @Throws(DeserializationError::class)
gradle.properties+1
@@ -2,3 +2,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
22android.useAndroidX=true
33kotlin.code.style=official
44android.nonTransitiveRClass=true
5+android.suppressUnsupportedCompileSdk=37.0
gradle/libs.versions.toml+4
@@ -12,6 +12,7 @@ lifecycle = "2.10.0"
1212ktor = "3.1.3"
1313kotlinxCoroutines = "1.10.2"
1414navigationCompose = "2.9.8"
15+markdownRenderer = "0.43.0"
1516[libraries]
1617androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
1718junit = { group = "junit", name = "junit", version.ref = "junit" }
@@ -31,6 +32,9 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material"
3132lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
3233androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
3334
35+markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3-android", version.ref = "markdownRenderer" }
36+markdown-renderer-core = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }
37+
3438ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
3539ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
3640ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
shared/build.gradle.kts+2−2
@@ -8,8 +8,8 @@ plugins {
88android {
99 namespace = "soy.iko.opencode.core"
1010
11− compileSdk = 36
12− buildToolsVersion = "36.0.0"
11+ compileSdk = 37
12+ buildToolsVersion = "37.0.0"
1313
1414 defaultConfig {
1515 minSdk = 26
shared/src/app.rs+118−5
@@ -42,6 +42,7 @@ pub enum Event {
4242 // Navigation
4343 NavigateToChat(String),
4444 NavigateToSessions,
45+ NavigateToConnect,
4546
4647 // Errors
4748 DismissError,
@@ -101,8 +102,11 @@ pub struct Model {
101102 error: Option<String>,
102103 sessions: Vec<SessionSummary>,
103104 current_session_id: Option<String>,
105+ current_session_title: String,
104106 messages: Vec<MessageView>,
105107 draft_message: String,
108+ /// True between sending a message and the assistant reply arriving.
109+ generating: bool,
106110 /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
107111 crash_logs: Vec<String>,
108112}
@@ -135,8 +139,11 @@ pub struct ViewModel {
135139 pub error: Option<String>,
136140 pub sessions: Vec<SessionView>,
137141 pub current_session_id: Option<String>,
142+ pub current_session_title: String,
138143 pub messages: Vec<MessageView>,
139144 pub draft_message: String,
145+ /// True while waiting for the assistant to reply after the user sends.
146+ pub generating: bool,
140147 pub crash_log_count: u32,
141148 pub latest_crash_log: Option<String>,
142149}
@@ -216,7 +223,8 @@ impl App for OpencodeApp {
216223 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
217224 match event {
218225 Event::Start => {
219− model.server_url = "http://localhost:4096".to_string();
226+ // Don't reset server_url here — the shell restores it from
227+ // persisted storage via ServerUrlChanged before any connect.
220228 render()
221229 }
222230
@@ -264,6 +272,13 @@ impl App for OpencodeApp {
264272 let body = response.take_body().unwrap_or_default();
265273 model.sessions = parse_sessions(&body);
266274 model.connected = true;
275+ // Refresh the current session's title in case it was
276+ // populated after creation.
277+ if let Some(id) = model.current_session_id.clone() {
278+ if let Some(s) = model.sessions.iter().find(|s| s.id == id) {
279+ model.current_session_title = s.title.clone();
280+ }
281+ }
267282 render()
268283 }
269284 Err(e) => {
@@ -275,18 +290,45 @@ impl App for OpencodeApp {
275290
276291 Event::SelectSession(id) => {
277292 model.current_session_id = Some(id.clone());
293+ model.current_session_title = model
294+ .sessions
295+ .iter()
296+ .find(|s| s.id == id)
297+ .map(|s| s.title.clone())
298+ .unwrap_or_default();
278299 Command::event(Event::NavigateToChat(id))
279300 }
280301
281302 Event::NavigateToChat(id) => {
282303 model.current_session_id = Some(id.clone());
304+ model.current_session_title = model
305+ .sessions
306+ .iter()
307+ .find(|s| s.id == id)
308+ .map(|s| s.title.clone())
309+ .unwrap_or_default();
283310 model.messages.clear();
284311 render().and(Command::event(Event::LoadMessages(id)))
285312 }
286313
287314 Event::NavigateToSessions => {
288315 model.current_session_id = None;
316+ model.current_session_title.clear();
317+ model.messages.clear();
318+ model.generating = false;
319+ render()
320+ }
321+
322+ Event::NavigateToConnect => {
323+ // Return to the connect screen to switch servers / credentials.
324+ // Keep the sessions list so a re-connect can refresh in place.
325+ model.current_session_id = None;
326+ model.current_session_title.clear();
289327 model.messages.clear();
328+ model.generating = false;
329+ model.connected = false;
330+ model.auth_required = false;
331+ model.loading = false;
290332 render()
291333 }
292334
@@ -303,6 +345,16 @@ impl App for OpencodeApp {
303345 Ok(mut response) => {
304346 let body = response.take_body().unwrap_or_default();
305347 let id = parse_session_id(&body);
348+ // Some servers echo the title in the created payload;
349+ // fall back to "New session" if absent.
350+ let title = extract_string(&body, "title")
351+ .filter(|t| !t.is_empty())
352+ .unwrap_or_else(|| "New session".to_string());
353+ model.current_session_title = title.clone();
354+ model.sessions.insert(
355+ 0,
356+ SessionSummary { id: id.clone(), title },
357+ );
306358 Command::event(Event::NavigateToChat(id))
307359 }
308360 Err(e) => {
@@ -320,6 +372,7 @@ impl App for OpencodeApp {
320372
321373 Event::MessagesLoaded(result) => {
322374 model.loading = false;
375+ model.generating = false;
323376 match result {
324377 Ok(mut response) => {
325378 let body = response.take_body().unwrap_or_default();
@@ -338,6 +391,7 @@ impl App for OpencodeApp {
338391 return render();
339392 };
340393 model.draft_message.clear();
394+ model.generating = true;
341395 let body = serde_json::json!({
342396 "sessionID": session_id,
343397 "parts": [{ "type": "text", "text": text }]
@@ -357,10 +411,12 @@ impl App for OpencodeApp {
357411 if let Some(session_id) = model.current_session_id.clone() {
358412 Command::event(Event::LoadMessages(session_id))
359413 } else {
414+ model.generating = false;
360415 render()
361416 }
362417 }
363418 Err(e) => {
419+ model.generating = false;
364420 model.error = Some(format!("Failed to send message: {e}"));
365421 render()
366422 }
@@ -418,8 +474,10 @@ impl App for OpencodeApp {
418474 })
419475 .collect(),
420476 current_session_id: model.current_session_id.clone(),
477+ current_session_title: model.current_session_title.clone(),
421478 messages: model.messages.clone(),
422479 draft_message: model.draft_message.clone(),
480+ generating: model.generating,
423481 crash_log_count: model.crash_logs.len() as u32,
424482 latest_crash_log: model.crash_logs.last().cloned(),
425483 }
@@ -604,11 +662,15 @@ mod test {
604662 use super::*;
605663
606664 #[test]
607− fn start_sets_default_server_url() {
665+ fn start_does_not_reset_server_url() {
666+ // Start no longer overwrites server_url — the shell restores the
667+ // persisted value via ServerUrlChanged so the user's last server
668+ // survives a relaunch.
608669 let app = OpencodeApp;
609670 let mut model = Model::default();
671+ model.server_url = "http://10.0.0.1:4096".to_string();
610672 app.update(Event::Start, &mut model).expect_only_render();
611− assert_eq!(model.server_url, "http://localhost:4096");
673+ assert_eq!(model.server_url, "http://10.0.0.1:4096");
612674 }
613675
614676 #[test]
@@ -628,6 +690,7 @@ mod test {
628690 fn connect_emits_http_effect() {
629691 let app = OpencodeApp;
630692 let mut model = Model::default();
693+ model.server_url = "http://localhost:4096".to_string();
631694 app.update(Event::Start, &mut model);
632695 let mut cmd = app.update(Event::Connect, &mut model);
633696 assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
@@ -669,6 +732,8 @@ mod test {
669732 let app = OpencodeApp;
670733 let mut model = Model::default();
671734 model.current_session_id = Some("test".to_string());
735+ model.current_session_title = "Test".to_string();
736+ model.generating = true;
672737 model.messages.push(MessageView {
673738 id: "m1".to_string(),
674739 role: "user".to_string(),
@@ -677,7 +742,53 @@ mod test {
677742 });
678743 app.update(Event::NavigateToSessions, &mut model);
679744 assert!(model.current_session_id.is_none());
745+ assert!(model.current_session_title.is_empty());
680746 assert!(model.messages.is_empty());
747+ assert!(!model.generating);
748+ }
749+
750+ #[test]
751+ fn select_session_records_title_from_sessions_list() {
752+ let app = OpencodeApp;
753+ let mut model = Model::default();
754+ model.sessions = vec![
755+ SessionSummary { id: "s1".to_string(), title: "First".to_string() },
756+ SessionSummary { id: "s2".to_string(), title: "Second".to_string() },
757+ ];
758+ app.update(Event::SelectSession("s2".to_string()), &mut model);
759+ assert_eq!(model.current_session_id.as_deref(), Some("s2"));
760+ assert_eq!(model.current_session_title, "Second");
761+ let view = app.view(&model);
762+ assert_eq!(view.current_session_title, "Second");
763+ }
764+
765+ #[test]
766+ fn send_message_sets_generating_until_messages_loaded() {
767+ let app = OpencodeApp;
768+ let mut model = Model::default();
769+ model.server_url = "http://localhost:4096".to_string();
770+ model.current_session_id = Some("s1".to_string());
771+ app.update(Event::SendMessage("hello".to_string()), &mut model);
772+ assert!(model.generating);
773+ // Simulate the MessagesLoaded internal event clearing it.
774+ let ok_resp = crux_http::testing::ResponseBuilder::ok().body("[]".to_string()).build();
775+ let result: HttpResult<String> = Ok(ok_resp);
776+ app.update(Event::MessagesLoaded(result), &mut model);
777+ assert!(!model.generating);
778+ }
779+
780+ #[test]
781+ fn failed_send_clears_generating() {
782+ let app = OpencodeApp;
783+ let mut model = Model::default();
784+ model.server_url = "http://localhost:4096".to_string();
785+ model.current_session_id = Some("s1".to_string());
786+ app.update(Event::SendMessage("hello".to_string()), &mut model);
787+ assert!(model.generating);
788+ let err = crux_http::HttpError::Io("boom".to_string());
789+ app.update(Event::MessageSent(Err(err)), &mut model);
790+ assert!(!model.generating);
791+ assert!(model.error.is_some());
681792 }
682793
683794 #[test]
@@ -717,7 +828,8 @@ mod test {
717828 // 0=Start, 1=ServerUrlChanged, 2=UsernameChanged, 3=PasswordChanged,
718829 // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
719830 // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
720− // 12=NavigateToChat, 13=NavigateToSessions, 14=DismissError
831+ // 12=NavigateToChat, 13=NavigateToSessions, 14=NavigateToConnect,
832+ // 15=DismissError
721833 //
722834 // Serialize each Rust variant and check the leading u32 index.
723835 let cases: &[(Event, u32)] = &[
@@ -735,7 +847,8 @@ mod test {
735847 (Event::EventReceived(String::new()), 11),
736848 (Event::NavigateToChat(String::new()), 12),
737849 (Event::NavigateToSessions, 13),
738− (Event::DismissError, 14),
850+ (Event::NavigateToConnect, 14),
851+ (Event::DismissError, 15),
739852 ];
740853 for (event, expected) in cases {
741854 let bytes = bincode::serialize(event).unwrap();