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
38cabc4iko
app/src/main/java/soy/iko/opencode/core/HttpClient.kt+1
@@ -51,6 +51,7 @@ class HttpClient(
51 ): Result<HttpResp> = withContext(Dispatchers.IO) {
51 ): Result<HttpResp> = withContext(Dispatchers.IO) {
52 runCatching {
52 runCatching {
53 val builder = Request.Builder().url(url).method()
53 val builder = Request.Builder().url(url).method()
54+ builder.header("User-Agent", "opencode-android/1.0")
54 if (auth != null) builder.header("Authorization", auth)
55 if (auth != null) builder.header("Authorization", auth)
55 await(okhttp.newCall(builder.build()))
56 await(okhttp.newCall(builder.build()))
56 }
57 }
app/src/main/java/soy/iko/opencode/core/SseClient.kt+1
@@ -33,6 +33,7 @@ class SseClient(
33
33
34 fun connect() {
34 fun connect() {
35 val builder = Request.Builder().url(url).header("Accept", "text/event-stream")
35 val builder = Request.Builder().url(url).header("Accept", "text/event-stream")
36+ builder.header("User-Agent", "opencode-android/1.0")
36 if (auth != null) builder.header("Authorization", auth)
37 if (auth != null) builder.header("Authorization", auth)
37 source = EventSources.createFactory(client).newEventSource(builder.build(), listener)
38 source = EventSources.createFactory(client).newEventSource(builder.build(), listener)
38 }
39 }
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+26−1
@@ -215,11 +215,36 @@ private fun buildLinkedText(
215 if (streaming) append(" ▌")
215 if (streaming) append(" ▌")
216}
216}
217
217
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+ */
219private fun AnnotatedString.Builder.appendLinkedText(
224private fun AnnotatedString.Builder.appendLinkedText(
220 text: String,
225 text: String,
221 linkColor: Color,
226 linkColor: Color,
222 context: Context,
227 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,
223) {
248) {
224 val matches = urlRegex.findAll(text).toList()
249 val matches = urlRegex.findAll(text).toList()
225 var cursor = 0
250 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
42import androidx.compose.material.icons.Icons
42import androidx.compose.material.icons.Icons
43import androidx.compose.material.icons.automirrored.filled.ArrowBack
43import androidx.compose.material.icons.automirrored.filled.ArrowBack
44import androidx.compose.material.icons.automirrored.filled.Send
44import androidx.compose.material.icons.automirrored.filled.Send
45+import androidx.compose.material.icons.filled.ChatBubbleOutline
45import androidx.compose.material.icons.filled.Close
46import androidx.compose.material.icons.filled.Close
46import androidx.compose.material.icons.filled.KeyboardArrowDown
47import androidx.compose.material.icons.filled.KeyboardArrowDown
47import androidx.compose.material.icons.filled.Stop
48import androidx.compose.material.icons.filled.Stop
@@ -194,12 +195,24 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
194 }
195 }
195 Box(Modifier.fillMaxWidth().weight(1f)) {
196 Box(Modifier.fillMaxWidth().weight(1f)) {
196 if (state.messages.isEmpty() && !state.loading) {
197 if (state.messages.isEmpty() && !state.loading) {
197− Text(
198+ Column(
198− text = "Send a message to start the conversation.",
199− style = MaterialTheme.typography.bodyMedium,
200− color = MaterialTheme.colorScheme.onSurfaceVariant,
201 modifier = Modifier.align(Alignment.Center).padding(32.dp),
199 modifier = Modifier.align(Alignment.Center).padding(32.dp),
200+ horizontalAlignment = Alignment.CenterHorizontally,
202− )
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+ }
203 }
216 }
204 LazyColumn(
217 LazyColumn(
205 state = listState,
218 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) {
187 }
187 }
188 }
188 }
189
189
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+
190 // Surface the error inline as well, so it stays visible while the user
202 // Surface the error inline as well, so it stays visible while the user
191 // corrects the form (the snackbar is transient).
203 // corrects the form (the snackbar is transient).
192 if (state.error != null && !state.loading) {
204 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: () ->
267 Icon(
267 Icon(
268 Icons.Filled.DeleteOutline,
268 Icons.Filled.DeleteOutline,
269 contentDescription = "Delete session",
269 contentDescription = "Delete session",
270− tint = MaterialTheme.colorScheme.onSurfaceVariant,
270+ tint = MaterialTheme.colorScheme.error,
271 )
271 )
272 }
272 }
273 }
273 }
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+4
@@ -17,6 +17,8 @@ private val DarkColors = darkColorScheme(
17 primaryContainer = Color(0xFF1A3D2A),
17 primaryContainer = Color(0xFF1A3D2A),
18 onPrimaryContainer = Color(0xFFA8E6C1),
18 onPrimaryContainer = Color(0xFFA8E6C1),
19 secondary = EmeraldDark,
19 secondary = EmeraldDark,
20+ secondaryContainer = Color(0xFF1A2A3D),
21+ onSecondaryContainer = Color(0xFFA8C5E6),
20 tertiaryContainer = Color(0xFF2A2A3D),
22 tertiaryContainer = Color(0xFF2A2A3D),
21 onTertiaryContainer = Color(0xFFC5C5E6),
23 onTertiaryContainer = Color(0xFFC5C5E6),
22 errorContainer = Color(0xFF4D2626),
24 errorContainer = Color(0xFF4D2626),
@@ -33,6 +35,8 @@ private val LightColors = lightColorScheme(
33 primaryContainer = Color(0xFFD7F5E3),
35 primaryContainer = Color(0xFFD7F5E3),
34 onPrimaryContainer = Color(0xFF0A3D22),
36 onPrimaryContainer = Color(0xFF0A3D22),
35 secondary = Emerald,
37 secondary = Emerald,
38+ secondaryContainer = Color(0xFFD7E8F5),
39+ onSecondaryContainer = Color(0xFF0A2A4D),
36 tertiaryContainer = Color(0xFFE8E8F5),
40 tertiaryContainer = Color(0xFFE8E8F5),
37 onTertiaryContainer = Color(0xFF2A2A4D),
41 onTertiaryContainer = Color(0xFF2A2A4D),
38 errorContainer = Color(0xFFFFE0E0),
42 errorContainer = Color(0xFFFFE0E0),