git.iko.soy/opencode-android
on master help sign in
Fix streaming scroll jank, HTTP credential leak on reactive auth, missing TextField labels and a11y roles, log URL leaks, tool spinner and loading a11y, touch target size, DraftStore main-thread disk race, unguarded coroutine launches, CrashLogger scope cleanup, and add testTags for UI testing
commit7f11220975cbfa855acdbe382903115824367db0 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 09:12 (3 months ago) parenta9143f4
20 files changed+212−81
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+12−3 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+32−13 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+18−2 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+36−8 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+6−3 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+4−3 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+6−6 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+20−18 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+2−1 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+5−2 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+5−4 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+14−4 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+6−1 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+2 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+9−2 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+8−1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+9−5 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+10−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+6−3 app/src/main/res/values/strings.xml+2
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+12−3
@@ -68,6 +68,13 @@ class EventStreamClient(
6868 e is io.ktor.client.plugins.ClientRequestException &&
6969 (e.response.status.value == 401 || e.response.status.value == 403)
7070
71+ /** A log-safe exception summary that avoids leaking the request URL (which
72+ * ClientRequestException embeds in its message and may contain auth or paths). */
73+ private fun safeExceptionSummary(e: Throwable): String {
74+ val status = (e as? io.ktor.client.plugins.ClientRequestException)?.response?.status?.value
75+ return if (status != null) "${e.javaClass.simpleName}($status)" else e.javaClass.simpleName
76+ }
77+
7178 /** Request an immediate reconnect, skipping any in-progress backoff. */
7279 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
7380
@@ -162,12 +169,14 @@ class EventStreamClient(
162169 // If the scope was cancelled (e.g. connection close), the closed-client
163170 // exception is expected — don't log a spurious "stream error" warning.
164171 if (isSseAuthFailure(e)) {
165− // Credentials are wrong — retrying won't help. Log and stop.
166− Log.w("EventStream", "SSE auth failed (401/403), stopping retries", e)
172+ // Credentials are wrong — retrying won't help. Log a scrubbed
173+ // summary (class + status) instead of the full exception, whose
174+ // message carries the request URL and may include auth or paths.
175+ Log.w("EventStream", "SSE auth failed (401/403), stopping retries: ${safeExceptionSummary(e)}")
167176 _state.value = ConnectionState.Failed
168177 break
169178 } else if (isActive) {
170− Log.w("EventStream", "SSE stream error, will retry", e)
179+ Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
171180 }
172181 }
173182 _state.value = ConnectionState.Disconnected
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+32−13
@@ -10,6 +10,7 @@ import io.ktor.client.plugins.auth.providers.basic
1010import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
1111import io.ktor.client.plugins.defaultRequest
1212import io.ktor.client.plugins.sse.SSE
13+import io.ktor.client.request.header
1314import io.ktor.http.URLBuilder
1415import io.ktor.http.takeFrom
1516import io.ktor.serialization.kotlinx.json.json
@@ -52,26 +53,44 @@ object HttpClientFactory {
5253 }
5354
5455 if (profile.hasAuth) {
55− install(Auth) {
56− basic {
57− credentials {
58− BasicAuthCredentials(
59− username = profile.username.orEmpty(),
60− password = profile.password.orEmpty(),
61− )
62− }
63− // Send eagerly so opencode doesn't need a 401 challenge round-trip,
64− // but only over HTTPS — sending credentials over cleartext HTTP
65− // would expose them on the network.
66− sendWithoutRequest { request ->
67− request.url.protocol.name == "https"
56+ val isHttps = normalizeBaseUrl(profile.baseUrl).startsWith("https://")
57+ if (isHttps) {
58+ install(Auth) {
59+ basic {
60+ credentials {
61+ BasicAuthCredentials(
62+ username = profile.username.orEmpty(),
63+ password = profile.password.orEmpty(),
64+ )
65+ }
66+ // Send eagerly so opencode doesn't need a 401 challenge round-trip.
67+ // The Auth plugin is only installed for HTTPS profiles — over HTTP,
68+ // even reactive (401-challenge) credential sending would leak
69+ // passwords in cleartext, so we don't install it at all.
70+ sendWithoutRequest { true }
6871 }
6972 }
73+ } else {
74+ // For HTTP profiles with auth, send credentials proactively in the
75+ // Authorization header via defaultRequest (below) so the user's intent
76+ // to connect over cleartext is honored, but we never install the Auth
77+ // plugin's reactive challenge-response which would silently re-send
78+ // credentials on any 401 without checking the protocol.
7079 }
7180 }
7281
7382 defaultRequest {
7483 url.takeFrom(URLBuilder().takeFrom(normalizeBaseUrl(profile.baseUrl)))
84+ // For HTTP profiles with auth, attach the Basic auth header proactively on
85+ // every request (the Auth plugin is skipped for non-HTTPS). The user chose
86+ // cleartext explicitly, so we honor that — but without the reactive challenge
87+ // logic that could re-send credentials silently.
88+ if (profile.hasAuth && !normalizeBaseUrl(profile.baseUrl).startsWith("https://")) {
89+ val credentials = java.util.Base64.getEncoder().encodeToString(
90+ "${profile.username.orEmpty()}:${profile.password.orEmpty()}".toByteArray(),
91+ )
92+ header("Authorization", "Basic $credentials")
93+ }
7594 }
7695 }
7796
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+18−2
@@ -8,6 +8,7 @@ import androidx.core.content.pm.PackageInfoCompat
88import kotlinx.coroutines.CoroutineScope
99import kotlinx.coroutines.Dispatchers
1010import kotlinx.coroutines.SupervisorJob
11+import kotlinx.coroutines.cancel
1112import kotlinx.coroutines.flow.MutableStateFlow
1213import kotlinx.coroutines.flow.StateFlow
1314import kotlinx.coroutines.flow.asStateFlow
@@ -18,6 +19,7 @@ import java.io.StringWriter
1819import java.text.SimpleDateFormat
1920import java.util.Date
2021import java.util.Locale
22+import soy.iko.opencode.util.runCatchingCancellable
2123
2224/**
2325 * A self-contained crash reporter with no network or third-party backend. An uncaught
@@ -37,6 +39,9 @@ class CrashLogger private constructor(private val appContext: Context) {
3739 val preview: String,
3840 )
3941
42+ /** Matches http(s) URLs so they can be redacted from crash report exception messages. */
43+ private val urlScrubRegex = Regex("https?://[^\\s\"']+")
44+
4045 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
4146 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
4247
@@ -53,7 +58,7 @@ class CrashLogger private constructor(private val appContext: Context) {
5358 // Load crash reports off the main thread so startup isn't blocked by file I/O
5459 // (listFiles + reading the first line of each report). The StateFlow updates
5560 // whenever the scan completes, so the Diagnostics screen reflects the result.
56− scope.launch { runCatching { refresh() } }
61+ scope.launch { runCatchingCancellable { refresh() } }
5762 }
5863
5964 fun refresh() {
@@ -90,6 +95,13 @@ class CrashLogger private constructor(private val appContext: Context) {
9095
9196 fun reportCount(): Int = _reports.value.size
9297
98+ /** Cancel the internal coroutine scope so background work doesn't outlive the
99+ * process. Called from [AppContainer.shutdown]. The UncaughtExceptionHandler is
100+ * intentionally left installed — a crash during shutdown still needs to be logged. */
101+ fun shutdown() {
102+ scope.cancel()
103+ }
104+
93105 private fun writeReport(thread: Thread, throwable: Throwable) {
94106 val now = Date()
95107 val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
@@ -107,7 +119,11 @@ class CrashLogger private constructor(private val appContext: Context) {
107119 pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
108120 pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
109121 pw.println()
110− pw.println(throwable.javaClass.name + ": " + throwable.message)
122+ // Scrub URLs from the exception message — Ktor/OkHttp embed the full request
123+ // URL in ClientRequestException messages, which may contain auth or internal
124+ // paths. The stack trace itself (class names + line numbers) is kept.
125+ val safeMessage = throwable.message?.let { urlScrubRegex.replace(it, "[url]") }
126+ pw.println(throwable.javaClass.name + ": " + safeMessage)
111127 throwable.printStackTrace(pw)
112128 }
113129 file.writeText(sw.toString())
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+36−8
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
99import kotlinx.coroutines.flow.update
1010import android.util.Log
1111import kotlinx.coroutines.launch
12+import kotlinx.coroutines.runBlocking
1213import kotlinx.coroutines.withContext
1314
1415/**
@@ -25,9 +26,16 @@ class DraftStore(context: Context, scope: CoroutineScope) {
2526
2627 private val appContext = context.applicationContext
2728 private val prefs by lazy {
29+ prefsInitialized.set(true)
2830 appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
2931 }
3032
33+ /** Tracks whether the [prefs] lazy has been resolved (i.e. the file has been
34+ * opened from disk). Accessed from [flushDraft] to decide whether a synchronous
35+ * write on the main thread is safe or whether the write must be deferred to a
36+ * background thread to avoid blocking on first-time disk I/O. */
37+ private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
38+
3139 private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
3240 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
3341 val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
@@ -84,9 +92,15 @@ class DraftStore(context: Context, scope: CoroutineScope) {
8492 /**
8593 * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
8694 * viewModelScope is already cancelled and a suspending write can't be used.
87− * Uses [apply] (asynchronous) rather than [commit] (synchronous) to avoid
88− * blocking the main thread — Android's SharedPreferences framework guarantees
89− * pending `apply()` writes are flushed to disk before the process exits.
95+ *
96+ * If the SharedPreferences file has already been opened (the normal case — the
97+ * background init load touches it shortly after construction), the write uses
98+ * [apply] (asynchronous) and returns immediately without blocking the main thread.
99+ *
100+ * If the prefs file has NOT been opened yet (a rare race where onCleared fires
101+ * before the background init), accessing the lazy would synchronously load and
102+ * parse the XML on the main thread. To avoid this, the write is deferred to a
103+ * blocking call on the IO dispatcher instead.
90104 */
91105 fun flushDraft(sessionId: String, text: String) {
92106 _drafts.update { current ->
@@ -94,10 +108,24 @@ class DraftStore(context: Context, scope: CoroutineScope) {
94108 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
95109 updated
96110 }
97− runCatching {
98− prefs.edit().apply {
99− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
100− }.apply()
101− }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
111+ if (prefsInitialized.get()) {
112+ // Prefs already opened — the lazy resolves instantly and apply() is async.
113+ runCatching {
114+ prefs.edit().apply {
115+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
116+ }.apply()
117+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
118+ } else {
119+ // Prefs not yet opened — avoid a synchronous disk read on the main thread
120+ // by blocking on IO instead. This is acceptable in onCleared() because the
121+ // viewModelScope is already dead and there's no suspending alternative.
122+ runCatching {
123+ runBlocking(Dispatchers.IO) {
124+ prefs.edit().apply {
125+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
126+ }.apply()
127+ }
128+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
129+ }
102130 }
103131}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+6−3
@@ -152,6 +152,7 @@ class AppContainer(context: Context) {
152152 }
153153 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
154154 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
155+ soy.iko.opencode.data.repo.CrashLogger.get(appContext).shutdown()
155156 appScope.cancel()
156157 }
157158
@@ -166,9 +167,10 @@ class AppContainer(context: Context) {
166167 @OptIn(ExperimentalCoroutinesApi::class)
167168 private fun observeMessageActivity() {
168169 appScope.launch {
169− activeConnection
170− .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
171− .collect { event ->
170+ runCatchingCancellable {
171+ activeConnection
172+ .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
173+ .collect { event ->
172174 // SessionIdle is not "message activity" (don't badge as unread)
173175 // but signals a run finished — fire a completion notification if the
174176 // session isn't currently being viewed and was actively streaming.
@@ -197,6 +199,7 @@ class AppContainer(context: Context) {
197199 }
198200 }
199201 }
202+ }.onFailure { Log.w("AppContainer", "Message activity observer failed", it) }
200203 }
201204 }
202205
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+4−3
@@ -23,6 +23,7 @@ import soy.iko.opencode.ui.session.SessionListScreen
2323import soy.iko.opencode.ui.session.TwoPaneSessionChat
2424import soy.iko.opencode.ui.settings.DiagnosticsScreen
2525import soy.iko.opencode.ui.settings.SettingsScreen
26+import soy.iko.opencode.util.runCatchingCancellable
2627
2728@Composable
2829fun OpencodeApp(container: AppContainer) {
@@ -86,7 +87,7 @@ fun OpencodeApp(container: AppContainer) {
8687 onOpenFiles = { navController.navigate(Routes.FILES) },
8788 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
8889 onDisconnect = {
89− scope.launch { container.disconnect() }
90+ scope.launch { runCatchingCancellable { container.disconnect() } }
9091 navController.popBackStack(Routes.SERVERS, inclusive = false)
9192 },
9293 )
@@ -94,11 +95,11 @@ fun OpencodeApp(container: AppContainer) {
9495 SessionListScreen(
9596 container = container,
9697 onOpenSession = { id ->
97− container.consumePendingShare()?.let { scope.launch { container.draftStore.set(id, it) } }
98+ container.consumePendingShare()?.let { scope.launch { runCatchingCancellable { container.draftStore.set(id, it) } } }
9899 navController.navigate(Routes.chat(id))
99100 },
100101 onDisconnect = {
101− scope.launch { container.disconnect() }
102+ scope.launch { runCatchingCancellable { container.disconnect() } }
102103 navController.popBackStack(Routes.SERVERS, inclusive = false)
103104 },
104105 onOpenFiles = { navController.navigate(Routes.FILES) },
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+6−6
@@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment
2222import androidx.compose.ui.Modifier
2323import androidx.compose.ui.res.stringResource
2424import androidx.compose.ui.semantics.Role
25+import androidx.compose.ui.semantics.contentDescription
2526import androidx.compose.ui.semantics.role
2627import androidx.compose.ui.semantics.selected
2728import androidx.compose.ui.semantics.semantics
@@ -49,11 +50,12 @@ fun AgentPickerSheet(
4950 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
5051 )
5152 if (loading) {
53+ val loadingLabel = stringResource(R.string.loading)
5254 Box(
5355 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
5456 contentAlignment = Alignment.Center,
5557 ) {
56− CircularProgressIndicator()
58+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
5759 }
5860 } else if (error && agents.isEmpty()) {
5961 Text(
@@ -62,7 +64,7 @@ fun AgentPickerSheet(
6264 color = MaterialTheme.colorScheme.error,
6365 modifier = Modifier
6466 .fillMaxWidth()
65− .clickable { onRetry() }
67+ .clickable(role = Role.Button) { onRetry() }
6668 .padding(horizontal = 24.dp, vertical = 16.dp),
6769 )
6870 } else if (agents.isEmpty()) {
@@ -79,10 +81,9 @@ fun AgentPickerSheet(
7981 modifier = Modifier
8082 .fillMaxWidth()
8183 .semantics(mergeDescendants = true) {
82− role = Role.RadioButton
8384 if (selected == null) this.selected = true
8485 }
85− .clickable { onSelect(null); onDismiss() }
86+ .clickable(role = Role.RadioButton) { onSelect(null); onDismiss() }
8687 .padding(horizontal = 24.dp, vertical = 12.dp),
8788 ) {
8889 Row(verticalAlignment = Alignment.CenterVertically) {
@@ -114,10 +115,9 @@ fun AgentPickerSheet(
114115 modifier = Modifier
115116 .fillMaxWidth()
116117 .semantics(mergeDescendants = true) {
117− role = Role.RadioButton
118118 if (isSelected) this.selected = true
119119 }
120− .clickable { onSelect(agent); onDismiss() }
120+ .clickable(role = Role.RadioButton) { onSelect(agent); onDismiss() }
121121 .padding(horizontal = 24.dp, vertical = 12.dp),
122122 ) {
123123 Row(verticalAlignment = Alignment.CenterVertically) {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+20−18
@@ -58,6 +58,7 @@ import androidx.compose.runtime.remember
5858import androidx.compose.runtime.rememberCoroutineScope
5959import androidx.compose.runtime.saveable.rememberSaveable
6060import androidx.compose.runtime.setValue
61+import androidx.compose.runtime.snapshotFlow
6162import androidx.compose.foundation.text.KeyboardActions
6263import androidx.compose.foundation.text.KeyboardOptions
6364import androidx.compose.ui.Alignment
@@ -71,6 +72,7 @@ import androidx.compose.ui.input.key.type
7172import androidx.compose.ui.platform.LocalContext
7273import androidx.compose.ui.platform.LocalHapticFeedback
7374import androidx.compose.ui.platform.LocalView
75+import androidx.compose.ui.platform.testTag
7476import androidx.compose.ui.res.stringResource
7577import androidx.compose.ui.semantics.Role
7678import androidx.compose.ui.semantics.contentDescription
@@ -80,15 +82,15 @@ import androidx.compose.ui.text.input.ImeAction
8082import androidx.compose.ui.unit.dp
8183import androidx.lifecycle.compose.collectAsStateWithLifecycle
8284import androidx.lifecycle.viewmodel.compose.viewModel
85+import kotlinx.coroutines.flow.collectLatest
8386import kotlinx.coroutines.launch
8487import soy.iko.opencode.data.network.EventStreamClient
85−import soy.iko.opencode.data.model.ReasoningPart
86−import soy.iko.opencode.data.model.TextPart
8788import soy.iko.opencode.di.AppContainer
8889import soy.iko.opencode.R
8990import soy.iko.opencode.ui.components.ConnectionBanner
9091import soy.iko.opencode.ui.components.toImageContext
9192import soy.iko.opencode.ui.vmFactory
93+import soy.iko.opencode.util.runCatchingCancellable
9294
9395@OptIn(ExperimentalMaterial3Api::class)
9496@Composable
@@ -174,19 +176,17 @@ fun ChatScreen(
174176 }
175177
176178 // Keep the newest content in view as parts stream in — only if already at the bottom.
177− // Include the growing text length so we follow streaming within a single part, not
178− // only when a brand-new message or part is added.
179− val lastPartLen = messages.lastOrNull()?.parts?.lastOrNull()?.let { part ->
180− when (part) {
181− is TextPart -> part.text.length
182− is ReasoningPart -> part.text.length
183− else -> 0
184− }
185− } ?: 0
186− LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size, lastPartLen) {
187− if (messages.isNotEmpty() && isPinnedToBottom) {
188− listState.animateScrollToItem(messages.lastIndex)
189− }
179+ // A single LaunchedEffect(Unit) + snapshotFlow avoids the rapid cancel/re-launch
180+ // churn that keying on lastPartLen (which changes on every streaming token) caused.
181+ // collectLatest cancels the in-flight scroll when a newer snapshot arrives, so
182+ // streaming stays smooth without dozens of competing animations per second.
183+ LaunchedEffect(Unit) {
184+ snapshotFlow { Triple(messages.size, messages.lastOrNull()?.parts?.size, isPinnedToBottom) }
185+ .collectLatest { (_, _, pinned) ->
186+ if (messages.isNotEmpty() && pinned) {
187+ listState.animateScrollToItem(messages.lastIndex)
188+ }
189+ }
190190 }
191191 val retryLabel = stringResource(R.string.retry)
192192 LaunchedEffect(error) {
@@ -337,7 +337,7 @@ fun ChatScreen(
337337 ExtendedFloatingActionButton(
338338 onClick = {
339339 if (messages.isNotEmpty()) {
340− scope.launch { listState.animateScrollToItem(messages.lastIndex) }
340+ scope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
341341 }
342342 },
343343 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
@@ -432,6 +432,7 @@ private fun ChatInputBar(
432432 onValueChange = onValueChange,
433433 modifier = Modifier
434434 .weight(1f)
435+ .testTag("chat_input")
435436 .onPreviewKeyEvent { event ->
436437 if (event.type == KeyEventType.KeyDown &&
437438 event.key == Key.Enter &&
@@ -445,20 +446,21 @@ private fun ChatInputBar(
445446 }
446447 },
447448 placeholder = { Text(stringResource(R.string.message_placeholder)) },
449+ label = { Text(stringResource(R.string.message_placeholder)) },
448450 enabled = enabled,
449451 maxLines = 6,
450452 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
451453 keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
452454 )
453455 if (running) {
454− IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
456+ IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp).testTag("stop_button")) {
455457 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
456458 }
457459 } else {
458460 IconButton(
459461 onClick = onSend,
460462 enabled = enabled && value.isNotBlank(),
461− modifier = Modifier.padding(start = 4.dp),
463+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
462464 ) {
463465 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
464466 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+2−1
@@ -198,7 +198,8 @@ class ChatViewModel(
198198 // Debounce draft persistence so we don't write to disk on every keystroke.
199199 viewModelScope.launch {
200200 _draft.drop(1).debounce(500).collect { text ->
201− container.draftStore.set(sessionId, text)
201+ runCatchingCancellable { container.draftStore.set(sessionId, text) }
202+ .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
202203 }
203204 }
204205 // Reset per-connection state when the active server changes so stale spinners,
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+5−2
@@ -20,7 +20,9 @@ import androidx.compose.ui.Alignment
2020import androidx.compose.ui.Modifier
2121import androidx.compose.ui.res.stringResource
2222import androidx.compose.ui.semantics.Role
23+import androidx.compose.ui.semantics.contentDescription
2324import androidx.compose.ui.semantics.role
25+import androidx.compose.ui.semantics.semantics
2426import androidx.compose.ui.text.style.TextOverflow
2527import androidx.compose.ui.unit.dp
2628import androidx.compose.foundation.layout.Box
@@ -45,11 +47,12 @@ fun CommandPickerSheet(
4547 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
4648 )
4749 if (loading) {
50+ val loadingLabel = stringResource(R.string.loading)
4851 Box(
4952 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
5053 contentAlignment = Alignment.Center,
5154 ) {
52− CircularProgressIndicator()
55+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
5356 }
5457 } else if (error && commands.isEmpty()) {
5558 Text(
@@ -58,7 +61,7 @@ fun CommandPickerSheet(
5861 color = MaterialTheme.colorScheme.error,
5962 modifier = Modifier
6063 .fillMaxWidth()
61− .clickable { onRetry() }
64+ .clickable(role = Role.Button) { onRetry() }
6265 .padding(horizontal = 24.dp, vertical = 16.dp),
6366 )
6467 } else if (commands.isEmpty()) {
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+5−4
@@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment
2222import androidx.compose.ui.Modifier
2323import androidx.compose.ui.res.stringResource
2424import androidx.compose.ui.semantics.Role
25+import androidx.compose.ui.semantics.contentDescription
2526import androidx.compose.ui.semantics.role
2627import androidx.compose.ui.semantics.selected
2728import androidx.compose.ui.semantics.semantics
@@ -49,11 +50,12 @@ fun ModelPickerSheet(
4950 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
5051 )
5152 if (loading) {
53+ val loadingLabel = stringResource(R.string.loading)
5254 Box(
5355 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
5456 contentAlignment = Alignment.Center,
5557 ) {
56− CircularProgressIndicator()
58+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
5759 }
5860 } else if (error && options.isEmpty()) {
5961 Text(
@@ -62,7 +64,7 @@ fun ModelPickerSheet(
6264 color = MaterialTheme.colorScheme.error,
6365 modifier = Modifier
6466 .fillMaxWidth()
65− .clickable { onRetry() }
67+ .clickable(role = Role.Button) { onRetry() }
6668 .padding(horizontal = 24.dp, vertical = 16.dp),
6769 )
6870 } else if (options.isEmpty()) {
@@ -81,10 +83,9 @@ fun ModelPickerSheet(
8183 modifier = Modifier
8284 .fillMaxWidth()
8385 .semantics(mergeDescendants = true) {
84− role = Role.RadioButton
8586 if (isSelected) this.selected = true
8687 }
87− .clickable {
88+ .clickable(role = Role.RadioButton) {
8889 onSelect(option)
8990 onDismiss()
9091 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+14−4
@@ -5,6 +5,7 @@ import androidx.compose.foundation.background
55import androidx.compose.foundation.clickable
66import androidx.compose.foundation.layout.Column
77import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.defaultMinSize
89import androidx.compose.foundation.layout.fillMaxWidth
910import androidx.compose.foundation.layout.padding
1011import androidx.compose.foundation.layout.size
@@ -99,6 +100,8 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
99100 Column(modifier = modifier.fillMaxWidth()) {
100101 Row(
101102 modifier = Modifier
103+ .fillMaxWidth()
104+ .defaultMinSize(minHeight = 48.dp)
102105 .clickable { expanded = !expanded }
103106 .padding(vertical = 4.dp)
104107 .semantics {
@@ -108,8 +111,9 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
108111 verticalAlignment = Alignment.CenterVertically,
109112 ) {
110113 if (streaming) {
114+ val thinkingLabel = stringResource(R.string.thinking)
111115 CircularProgressIndicator(
112− Modifier.size(14.dp),
116+ Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
113117 strokeWidth = 2.dp,
114118 color = MaterialTheme.colorScheme.onSurfaceVariant,
115119 )
@@ -221,11 +225,13 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
221225@Composable
222226private fun ToolStatusIcon(state: ToolState) {
223227 when (state) {
224− is ToolPending, is ToolRunning ->
228+ is ToolPending, is ToolRunning -> {
229+ val label = stringResource(R.string.tool_running)
225230 CircularProgressIndicator(
226− modifier = Modifier.size(16.dp),
231+ modifier = Modifier.size(16.dp).semantics { contentDescription = label },
227232 strokeWidth = 2.dp,
228233 )
234+ }
229235 is ToolCompleted ->
230236 Icon(
231237 Icons.Filled.CheckCircle,
@@ -241,7 +247,11 @@ private fun ToolStatusIcon(state: ToolState) {
241247 tint = MaterialTheme.colorScheme.error,
242248 )
243249 is ToolUnknown ->
244− Icon(Icons.Filled.Bolt, contentDescription = null, modifier = Modifier.size(16.dp))
250+ Icon(
251+ Icons.Filled.Bolt,
252+ contentDescription = stringResource(R.string.tool_unknown),
253+ modifier = Modifier.size(16.dp),
254+ )
245255 }
246256}
247257
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+6−1
@@ -13,6 +13,8 @@ import androidx.compose.runtime.Composable
1313import androidx.compose.ui.Alignment
1414import androidx.compose.ui.Modifier
1515import androidx.compose.ui.res.stringResource
16+import androidx.compose.ui.semantics.contentDescription
17+import androidx.compose.ui.semantics.semantics
1618import androidx.compose.ui.unit.dp
1719import soy.iko.opencode.R
1820import soy.iko.opencode.data.network.EventStreamClient
@@ -42,7 +44,10 @@ fun ConnectionBanner(
4244 horizontalArrangement = Arrangement.Center,
4345 verticalAlignment = Alignment.CenterVertically,
4446 ) {
45− CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
47+ CircularProgressIndicator(
48+ Modifier.size(12.dp).semantics { contentDescription = text },
49+ strokeWidth = 2.dp,
50+ )
4651 Text(
4752 text,
4853 style = MaterialTheme.typography.labelMedium,
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+2
@@ -31,6 +31,7 @@ import androidx.compose.ui.Alignment
3131import androidx.compose.ui.Modifier
3232import androidx.compose.ui.draw.clip
3333import androidx.compose.ui.platform.LocalContext
34+import androidx.compose.ui.semantics.Role
3435import androidx.compose.ui.text.TextStyle
3536import androidx.compose.ui.text.font.FontFamily
3637import androidx.compose.ui.unit.dp
@@ -81,6 +82,7 @@ fun MarkdownText(
8182 modifier = modifier.combinedClickable(
8283 onClick = {},
8384 onLongClick = { copyToClipboard(context, markdown) },
85+ role = Role.Button,
8486 ),
8587 components = markdownComponents(
8688 codeFence = { CodeWithCopy(it) },
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+9−2
@@ -39,6 +39,7 @@ import androidx.compose.runtime.remember
3939import androidx.compose.ui.Alignment
4040import androidx.compose.ui.Modifier
4141import androidx.compose.ui.graphics.Color
42+import androidx.compose.ui.platform.testTag
4243import androidx.compose.ui.semantics.Role
4344import androidx.compose.ui.semantics.contentDescription
4445import androidx.compose.ui.semantics.role
@@ -96,8 +97,9 @@ fun FileBrowserScreen(
9697 OutlinedTextField(
9798 value = state.query,
9899 onValueChange = vm::setQuery,
99− modifier = Modifier.fillMaxWidth().padding(12.dp),
100+ modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
100101 placeholder = { Text(stringResource(R.string.search_files)) },
102+ label = { Text(stringResource(R.string.search_files)) },
101103 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102104 singleLine = true,
103105 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
@@ -105,7 +107,12 @@ fun FileBrowserScreen(
105107
106108 Box(modifier = Modifier.fillMaxSize()) {
107109 when {
108− state.loading || state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
110+ state.loading || state.searching -> {
111+ val loadingLabel = stringResource(R.string.loading)
112+ CircularProgressIndicator(
113+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
114+ )
115+ }
109116 state.error != null && !state.isSearching -> Text(
110117 state.error ?: "",
111118 modifier = Modifier.align(Alignment.Center).padding(24.dp),
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+8−1
@@ -29,6 +29,8 @@ import androidx.compose.runtime.remember
2929import androidx.compose.ui.Alignment
3030import androidx.compose.ui.Modifier
3131import androidx.compose.ui.platform.LocalContext
32+import androidx.compose.ui.semantics.contentDescription
33+import androidx.compose.ui.semantics.semantics
3234import androidx.compose.ui.text.font.FontFamily
3335import androidx.compose.ui.text.style.TextOverflow
3436import androidx.compose.ui.unit.dp
@@ -86,7 +88,12 @@ fun FileViewScreen(
8688 ) { padding ->
8789 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
8890 when {
89− state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
91+ state.loading -> {
92+ val loadingLabel = stringResource(R.string.loading)
93+ CircularProgressIndicator(
94+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
95+ )
96+ }
9097 state.error != null -> Text(
9198 state.error ?: "",
9299 modifier = Modifier.align(Alignment.Center).padding(24.dp),
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+9−5
@@ -29,6 +29,9 @@ import androidx.compose.runtime.Composable
2929import androidx.compose.runtime.getValue
3030import androidx.compose.ui.Alignment
3131import androidx.compose.ui.Modifier
32+import androidx.compose.ui.platform.testTag
33+import androidx.compose.ui.semantics.contentDescription
34+import androidx.compose.ui.semantics.semantics
3235import androidx.compose.ui.text.input.ImeAction
3336import androidx.compose.ui.text.input.KeyboardType
3437import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -64,8 +67,9 @@ fun ServerEditScreen(
6467 },
6568 ) { padding ->
6669 if (!state.loaded) {
70+ val loadingLabel = stringResource(R.string.loading)
6771 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
68− CircularProgressIndicator()
72+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
6973 }
7074 } else {
7175 Column(
@@ -83,7 +87,7 @@ fun ServerEditScreen(
8387 label = { Text(stringResource(R.string.label_optional)) },
8488 singleLine = true,
8589 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
86− modifier = Modifier.fillMaxWidth(),
90+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
8791 )
8892 OutlinedTextField(
8993 value = state.baseUrl,
@@ -92,7 +96,7 @@ fun ServerEditScreen(
9296 placeholder = { Text(stringResource(R.string.base_url_hint)) },
9397 singleLine = true,
9498 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
95− modifier = Modifier.fillMaxWidth(),
99+ modifier = Modifier.fillMaxWidth().testTag("server_url"),
96100 )
97101 Text(
98102 stringResource(R.string.auth_help),
@@ -104,7 +108,7 @@ fun ServerEditScreen(
104108 label = { Text(stringResource(R.string.username_optional)) },
105109 singleLine = true,
106110 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
107− modifier = Modifier.fillMaxWidth(),
111+ modifier = Modifier.fillMaxWidth().testTag("server_username"),
108112 )
109113 OutlinedTextField(
110114 value = state.password,
@@ -114,7 +118,7 @@ fun ServerEditScreen(
114118 visualTransformation = PasswordVisualTransformation(),
115119 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
116120 keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
117− modifier = Modifier.fillMaxWidth(),
121+ modifier = Modifier.fillMaxWidth().testTag("server_password"),
118122 )
119123 Button(
120124 onClick = { vm.save(onDone) },
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+10−2
@@ -43,6 +43,10 @@ import androidx.compose.runtime.setValue
4343import androidx.compose.ui.Alignment
4444import androidx.compose.ui.Modifier
4545import androidx.compose.ui.platform.LocalHapticFeedback
46+import androidx.compose.ui.platform.testTag
47+import androidx.compose.ui.semantics.Role
48+import androidx.compose.ui.semantics.contentDescription
49+import androidx.compose.ui.semantics.semantics
4650import androidx.compose.ui.text.style.TextOverflow
4751import androidx.compose.ui.unit.dp
4852import androidx.compose.ui.res.stringResource
@@ -120,7 +124,8 @@ fun ServerListScreen(
120124 modifier = Modifier
121125 .fillMaxWidth()
122126 .animateItem()
123− .clickable(enabled = connectingId == null) {
127+ .testTag("server_card")
128+ .clickable(enabled = connectingId == null, role = Role.Button) {
124129 vm.connect(profile, onConnected)
125130 },
126131 ) {
@@ -158,7 +163,10 @@ fun ServerListScreen(
158163 modifier = Modifier.size(22.dp),
159164 )
160165 } else if (connectingId == profile.id) {
161− CircularProgressIndicator(modifier = Modifier.size(24.dp))
166+ val connectingLabel = stringResource(R.string.connecting)
167+ CircularProgressIndicator(
168+ modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
169+ )
162170 } else {
163171 IconButton(onClick = { onEditProfile(profile.id) }) {
164172 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+6−3
@@ -59,6 +59,7 @@ import androidx.compose.ui.Alignment
5959import androidx.compose.ui.Modifier
6060import androidx.compose.ui.draw.clip
6161import androidx.compose.ui.platform.LocalHapticFeedback
62+import androidx.compose.ui.platform.testTag
6263import androidx.compose.ui.semantics.Role
6364import androidx.compose.ui.semantics.contentDescription
6465import androidx.compose.ui.semantics.role
@@ -219,8 +220,9 @@ fun SessionListScreen(
219220 OutlinedTextField(
220221 value = state.query,
221222 onValueChange = vm::setQuery,
222− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
223+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
223224 placeholder = { Text(stringResource(R.string.search_sessions)) },
225+ label = { Text(stringResource(R.string.search_sessions)) },
224226 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
225227 singleLine = true,
226228 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
@@ -251,7 +253,7 @@ fun SessionListScreen(
251253 onClick = { onOpenSession(session.id) },
252254 onRename = { pendingRenameId = session.id },
253255 onDelete = { pendingDeleteId = session.id },
254− modifier = Modifier.animateItem(),
256+ modifier = Modifier.animateItem().testTag("session_card"),
255257 )
256258 }
257259 }
@@ -311,6 +313,7 @@ private fun RenameSessionDialog(
311313 onValueChange = onTitleChange,
312314 modifier = Modifier.fillMaxWidth(),
313315 placeholder = { Text(stringResource(R.string.session_title_hint)) },
316+ label = { Text(stringResource(R.string.rename_session)) },
314317 singleLine = true,
315318 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
316319 keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm() }),
@@ -412,7 +415,7 @@ private fun SessionCard(
412415 color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
413416 maxLines = 2,
414417 overflow = TextOverflow.Ellipsis,
415− modifier = Modifier.clickable { onClick() },
418+ modifier = Modifier.clickable(role = Role.Button) { onClick() },
416419 )
417420 }
418421 }
app/src/main/res/values/strings.xml+2
@@ -103,6 +103,8 @@
103103 <string name="show_less">Show less</string>
104104 <string name="tool_completed">Completed</string>
105105 <string name="tool_error">Error</string>
106+ <string name="tool_running">Running</string>
107+ <string name="tool_unknown">Unknown tool state</string>
106108 <string name="state_expanded">Expanded</string>
107109 <string name="state_collapsed">Collapsed</string>
108110