git.iko.soy/opencode-android
on master help sign in
UX/UI: chat empty state icon, error-tinted delete button, connecting message, inline code formatting

Bug fixes: add User-Agent header to HTTP/SSE requests, add missing M3 secondaryContainer color roles

commit38cabc46b02f57ae0324725c0357e25273856734 authoriko <mail@iko.soy> dateThu 9 Jul 2026, 03:56 (3 months ago) parent0c49daf
7 files changed+63−7
app/src/main/java/soy/iko/opencode/core/HttpClient.kt+1 app/src/main/java/soy/iko/opencode/core/SseClient.kt+1 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+26−1 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+18−5 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+12 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+1−1 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+4
app/src/main/java/soy/iko/opencode/core/HttpClient.kt+1
@@ -51,6 +51,7 @@ class HttpClient(
5151 ): Result<HttpResp> = withContext(Dispatchers.IO) {
5252 runCatching {
5353 val builder = Request.Builder().url(url).method()
54+ builder.header("User-Agent", "opencode-android/1.0")
5455 if (auth != null) builder.header("Authorization", auth)
5556 await(okhttp.newCall(builder.build()))
5657 }
app/src/main/java/soy/iko/opencode/core/SseClient.kt+1
@@ -33,6 +33,7 @@ class SseClient(
3333
3434 fun connect() {
3535 val builder = Request.Builder().url(url).header("Accept", "text/event-stream")
36+ builder.header("User-Agent", "opencode-android/1.0")
3637 if (auth != null) builder.header("Authorization", auth)
3738 source = EventSources.createFactory(client).newEventSource(builder.build(), listener)
3839 }
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+26−1
@@ -215,11 +215,36 @@ private fun buildLinkedText(
215215 if (streaming) append(" ▌")
216216}
217217
218−/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
218+/**
219+ * Append [text] to the builder. Single-backtick pairs are rendered as inline code
220+ * (monospace, backticks dropped); the remaining prose has each http(s) URL turned
221+ * into a clickable link. Triple-backtick fences are already stripped upstream in
222+ * [buildLinkedText], so only inline spans reach here.
223+ */
219224private fun AnnotatedString.Builder.appendLinkedText(
220225 text: String,
221226 linkColor: Color,
222227 context: Context,
228+) {
229+ // Odd-indexed segments sit between a pair of single backticks, so they're inline
230+ // code. Even segments are ordinary prose and get URL linking applied.
231+ val segments = text.split("`")
232+ segments.forEachIndexed { index, segment ->
233+ if (index % 2 == 1) {
234+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) {
235+ append(segment)
236+ }
237+ } else {
238+ appendUrls(segment, linkColor, context)
239+ }
240+ }
241+}
242+
243+/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
244+private fun AnnotatedString.Builder.appendUrls(
245+ text: String,
246+ linkColor: Color,
247+ context: Context,
223248) {
224249 val matches = urlRegex.findAll(text).toList()
225250 var cursor = 0
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+18−5
@@ -42,6 +42,7 @@ import androidx.compose.foundation.text.KeyboardOptions
4242import androidx.compose.material.icons.Icons
4343import androidx.compose.material.icons.automirrored.filled.ArrowBack
4444import androidx.compose.material.icons.automirrored.filled.Send
45+import androidx.compose.material.icons.filled.ChatBubbleOutline
4546import androidx.compose.material.icons.filled.Close
4647import androidx.compose.material.icons.filled.KeyboardArrowDown
4748import androidx.compose.material.icons.filled.Stop
@@ -194,12 +195,24 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
194195 }
195196 Box(Modifier.fillMaxWidth().weight(1f)) {
196197 if (state.messages.isEmpty() && !state.loading) {
197− Text(
198− text = "Send a message to start the conversation.",
199− style = MaterialTheme.typography.bodyMedium,
200− color = MaterialTheme.colorScheme.onSurfaceVariant,
198+ Column(
201199 modifier = Modifier.align(Alignment.Center).padding(32.dp),
202− )
200+ horizontalAlignment = Alignment.CenterHorizontally,
201+ ) {
202+ Icon(
203+ Icons.Filled.ChatBubbleOutline,
204+ contentDescription = null,
205+ modifier = Modifier.size(48.dp),
206+ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
207+ )
208+ Spacer(Modifier.size(16.dp))
209+ Text(
210+ text = "Send a message to start the conversation.",
211+ style = MaterialTheme.typography.bodyMedium,
212+ color = MaterialTheme.colorScheme.onSurfaceVariant,
213+ textAlign = TextAlign.Center,
214+ )
215+ }
203216 }
204217 LazyColumn(
205218 state = listState,
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+12
@@ -187,6 +187,18 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
187187 }
188188 }
189189
190+ // While connecting (including auto-connect on launch), echo the target URL
191+ // so the user knows what the spinner is waiting on.
192+ if (state.loading) {
193+ Spacer(Modifier.height(8.dp))
194+ Text(
195+ text = "Connecting to ${state.serverUrl}...",
196+ style = MaterialTheme.typography.bodySmall,
197+ color = MaterialTheme.colorScheme.onSurfaceVariant,
198+ textAlign = TextAlign.Center,
199+ )
200+ }
201+
190202 // Surface the error inline as well, so it stays visible while the user
191203 // corrects the form (the snackbar is transient).
192204 if (state.error != null && !state.loading) {
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+1−1
@@ -267,7 +267,7 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
267267 Icon(
268268 Icons.Filled.DeleteOutline,
269269 contentDescription = "Delete session",
270− tint = MaterialTheme.colorScheme.onSurfaceVariant,
270+ tint = MaterialTheme.colorScheme.error,
271271 )
272272 }
273273 }
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+4
@@ -17,6 +17,8 @@ private val DarkColors = darkColorScheme(
1717 primaryContainer = Color(0xFF1A3D2A),
1818 onPrimaryContainer = Color(0xFFA8E6C1),
1919 secondary = EmeraldDark,
20+ secondaryContainer = Color(0xFF1A2A3D),
21+ onSecondaryContainer = Color(0xFFA8C5E6),
2022 tertiaryContainer = Color(0xFF2A2A3D),
2123 onTertiaryContainer = Color(0xFFC5C5E6),
2224 errorContainer = Color(0xFF4D2626),
@@ -33,6 +35,8 @@ private val LightColors = lightColorScheme(
3335 primaryContainer = Color(0xFFD7F5E3),
3436 onPrimaryContainer = Color(0xFF0A3D22),
3537 secondary = Emerald,
38+ secondaryContainer = Color(0xFFD7E8F5),
39+ onSecondaryContainer = Color(0xFF0A2A4D),
3640 tertiaryContainer = Color(0xFFE8E8F5),
3741 onTertiaryContainer = Color(0xFF2A2A4D),
3842 errorContainer = Color(0xFFFFE0E0),