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
7f11220iko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+12−3
@@ -68,6 +68,13 @@ class EventStreamClient(
68 e is io.ktor.client.plugins.ClientRequestException &&
68 e is io.ktor.client.plugins.ClientRequestException &&
69 (e.response.status.value == 401 || e.response.status.value == 403)
69 (e.response.status.value == 401 || e.response.status.value == 403)
70
70
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+
71 /** Request an immediate reconnect, skipping any in-progress backoff. */
78 /** Request an immediate reconnect, skipping any in-progress backoff. */
72 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
79 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
73
80
@@ -162,12 +169,14 @@ class EventStreamClient(
162 // If the scope was cancelled (e.g. connection close), the closed-client
169 // If the scope was cancelled (e.g. connection close), the closed-client
163 // exception is expected — don't log a spurious "stream error" warning.
170 // exception is expected — don't log a spurious "stream error" warning.
164 if (isSseAuthFailure(e)) {
171 if (isSseAuthFailure(e)) {
165− // Credentials are wrong — retrying won't help. Log and stop.
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.
166− Log.w("EventStream", "SSE auth failed (401/403), stopping retries", e)
175+ Log.w("EventStream", "SSE auth failed (401/403), stopping retries: ${safeExceptionSummary(e)}")
167 _state.value = ConnectionState.Failed
176 _state.value = ConnectionState.Failed
168 break
177 break
169 } else if (isActive) {
178 } else if (isActive) {
170− Log.w("EventStream", "SSE stream error, will retry", e)
179+ Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
171 }
180 }
172 }
181 }
173 _state.value = ConnectionState.Disconnected
182 _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
10import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
10import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
11import io.ktor.client.plugins.defaultRequest
11import io.ktor.client.plugins.defaultRequest
12import io.ktor.client.plugins.sse.SSE
12import io.ktor.client.plugins.sse.SSE
13+import io.ktor.client.request.header
13import io.ktor.http.URLBuilder
14import io.ktor.http.URLBuilder
14import io.ktor.http.takeFrom
15import io.ktor.http.takeFrom
15import io.ktor.serialization.kotlinx.json.json
16import io.ktor.serialization.kotlinx.json.json
@@ -52,26 +53,44 @@ object HttpClientFactory {
52 }
53 }
53
54
54 if (profile.hasAuth) {
55 if (profile.hasAuth) {
55− install(Auth) {
56+ val isHttps = normalizeBaseUrl(profile.baseUrl).startsWith("https://")
57+ if (isHttps) {
58+ install(Auth) {
56− basic {
59+ basic {
57− credentials {
60+ credentials {
58− BasicAuthCredentials(
61+ BasicAuthCredentials(
59− username = profile.username.orEmpty(),
62+ username = profile.username.orEmpty(),
60− password = profile.password.orEmpty(),
63+ password = profile.password.orEmpty(),
61− )
64+ )
62− }
65+ }
63− // Send eagerly so opencode doesn't need a 401 challenge round-trip,
66+ // Send eagerly so opencode doesn't need a 401 challenge round-trip.
64− // but only over HTTPS — sending credentials over cleartext HTTP
67+ // The Auth plugin is only installed for HTTPS profiles — over HTTP,
65− // would expose them on the network.
68+ // even reactive (401-challenge) credential sending would leak
69+ // passwords in cleartext, so we don't install it at all.
66− sendWithoutRequest { request ->
70+ sendWithoutRequest { true }
67− request.url.protocol.name == "https"
68 }
71 }
69 }
72 }
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.
70 }
79 }
71 }
80 }
72
81
73 defaultRequest {
82 defaultRequest {
74 url.takeFrom(URLBuilder().takeFrom(normalizeBaseUrl(profile.baseUrl)))
83 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+ }
75 }
94 }
76 }
95 }
77
96
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+18−2
@@ -8,6 +8,7 @@ import androidx.core.content.pm.PackageInfoCompat
8import kotlinx.coroutines.CoroutineScope
8import kotlinx.coroutines.CoroutineScope
9import kotlinx.coroutines.Dispatchers
9import kotlinx.coroutines.Dispatchers
10import kotlinx.coroutines.SupervisorJob
10import kotlinx.coroutines.SupervisorJob
11+import kotlinx.coroutines.cancel
11import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.asStateFlow
14import kotlinx.coroutines.flow.asStateFlow
@@ -18,6 +19,7 @@ import java.io.StringWriter
18import java.text.SimpleDateFormat
19import java.text.SimpleDateFormat
19import java.util.Date
20import java.util.Date
20import java.util.Locale
21import java.util.Locale
22+import soy.iko.opencode.util.runCatchingCancellable
21
23
22/**
24/**
23 * A self-contained crash reporter with no network or third-party backend. An uncaught
25 * 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) {
37 val preview: String,
39 val preview: String,
38 )
40 )
39
41
42+ /** Matches http(s) URLs so they can be redacted from crash report exception messages. */
43+ private val urlScrubRegex = Regex("https?://[^\\s\"']+")
44+
40 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
45 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
41 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
46 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
42
47
@@ -53,7 +58,7 @@ class CrashLogger private constructor(private val appContext: Context) {
53 // Load crash reports off the main thread so startup isn't blocked by file I/O
58 // Load crash reports off the main thread so startup isn't blocked by file I/O
54 // (listFiles + reading the first line of each report). The StateFlow updates
59 // (listFiles + reading the first line of each report). The StateFlow updates
55 // whenever the scan completes, so the Diagnostics screen reflects the result.
60 // whenever the scan completes, so the Diagnostics screen reflects the result.
56− scope.launch { runCatching { refresh() } }
61+ scope.launch { runCatchingCancellable { refresh() } }
57 }
62 }
58
63
59 fun refresh() {
64 fun refresh() {
@@ -90,6 +95,13 @@ class CrashLogger private constructor(private val appContext: Context) {
90
95
91 fun reportCount(): Int = _reports.value.size
96 fun reportCount(): Int = _reports.value.size
92
97
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+
93 private fun writeReport(thread: Thread, throwable: Throwable) {
105 private fun writeReport(thread: Thread, throwable: Throwable) {
94 val now = Date()
106 val now = Date()
95 val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
107 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) {
107 pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
119 pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
108 pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
120 pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
109 pw.println()
121 pw.println()
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]") }
110− pw.println(throwable.javaClass.name + ": " + throwable.message)
126+ pw.println(throwable.javaClass.name + ": " + safeMessage)
111 throwable.printStackTrace(pw)
127 throwable.printStackTrace(pw)
112 }
128 }
113 file.writeText(sw.toString())
129 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
9import kotlinx.coroutines.flow.update
9import kotlinx.coroutines.flow.update
10import android.util.Log
10import android.util.Log
11import kotlinx.coroutines.launch
11import kotlinx.coroutines.launch
12+import kotlinx.coroutines.runBlocking
12import kotlinx.coroutines.withContext
13import kotlinx.coroutines.withContext
13
14
14/**
15/**
@@ -25,9 +26,16 @@ class DraftStore(context: Context, scope: CoroutineScope) {
25
26
26 private val appContext = context.applicationContext
27 private val appContext = context.applicationContext
27 private val prefs by lazy {
28 private val prefs by lazy {
29+ prefsInitialized.set(true)
28 appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
30 appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
29 }
31 }
30
32
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+
31 private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
39 private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
32 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
40 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
33 val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
41 val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
@@ -84,9 +92,15 @@ class DraftStore(context: Context, scope: CoroutineScope) {
84 /**
92 /**
85 * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
93 * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
86 * viewModelScope is already cancelled and a suspending write can't be used.
94 * viewModelScope is already cancelled and a suspending write can't be used.
87− * Uses [apply] (asynchronous) rather than [commit] (synchronous) to avoid
95+ *
88− * blocking the main thread — Android's SharedPreferences framework guarantees
96+ * If the SharedPreferences file has already been opened (the normal case — the
89− * pending `apply()` writes are flushed to disk before the process exits.
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.
90 */
104 */
91 fun flushDraft(sessionId: String, text: String) {
105 fun flushDraft(sessionId: String, text: String) {
92 _drafts.update { current ->
106 _drafts.update { current ->
@@ -94,10 +108,24 @@ class DraftStore(context: Context, scope: CoroutineScope) {
94 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
108 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
95 updated
109 updated
96 }
110 }
97− runCatching {
111+ if (prefsInitialized.get()) {
112+ // Prefs already opened — the lazy resolves instantly and apply() is async.
113+ runCatching {
98− prefs.edit().apply {
114+ prefs.edit().apply {
99− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
115+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
100− }.apply()
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+ }
101− }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
128+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
129+ }
102 }
130 }
103}
131}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+6−3
@@ -152,6 +152,7 @@ class AppContainer(context: Context) {
152 }
152 }
153 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
153 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
154 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
154 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
155+ soy.iko.opencode.data.repo.CrashLogger.get(appContext).shutdown()
155 appScope.cancel()
156 appScope.cancel()
156 }
157 }
157
158
@@ -166,9 +167,10 @@ class AppContainer(context: Context) {
166 @OptIn(ExperimentalCoroutinesApi::class)
167 @OptIn(ExperimentalCoroutinesApi::class)
167 private fun observeMessageActivity() {
168 private fun observeMessageActivity() {
168 appScope.launch {
169 appScope.launch {
170+ runCatchingCancellable {
169− activeConnection
171+ activeConnection
170− .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
172+ .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
171− .collect { event ->
173+ .collect { event ->
172 // SessionIdle is not "message activity" (don't badge as unread)
174 // SessionIdle is not "message activity" (don't badge as unread)
173 // but signals a run finished — fire a completion notification if the
175 // but signals a run finished — fire a completion notification if the
174 // session isn't currently being viewed and was actively streaming.
176 // session isn't currently being viewed and was actively streaming.
@@ -197,6 +199,7 @@ class AppContainer(context: Context) {
197 }
199 }
198 }
200 }
199 }
201 }
202+ }.onFailure { Log.w("AppContainer", "Message activity observer failed", it) }
200 }
203 }
201 }
204 }
202
205
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+4−3
@@ -23,6 +23,7 @@ import soy.iko.opencode.ui.session.SessionListScreen
23import soy.iko.opencode.ui.session.TwoPaneSessionChat
23import soy.iko.opencode.ui.session.TwoPaneSessionChat
24import soy.iko.opencode.ui.settings.DiagnosticsScreen
24import soy.iko.opencode.ui.settings.DiagnosticsScreen
25import soy.iko.opencode.ui.settings.SettingsScreen
25import soy.iko.opencode.ui.settings.SettingsScreen
26+import soy.iko.opencode.util.runCatchingCancellable
26
27
27@Composable
28@Composable
28fun OpencodeApp(container: AppContainer) {
29fun OpencodeApp(container: AppContainer) {
@@ -86,7 +87,7 @@ fun OpencodeApp(container: AppContainer) {
86 onOpenFiles = { navController.navigate(Routes.FILES) },
87 onOpenFiles = { navController.navigate(Routes.FILES) },
87 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
88 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
88 onDisconnect = {
89 onDisconnect = {
89− scope.launch { container.disconnect() }
90+ scope.launch { runCatchingCancellable { container.disconnect() } }
90 navController.popBackStack(Routes.SERVERS, inclusive = false)
91 navController.popBackStack(Routes.SERVERS, inclusive = false)
91 },
92 },
92 )
93 )
@@ -94,11 +95,11 @@ fun OpencodeApp(container: AppContainer) {
94 SessionListScreen(
95 SessionListScreen(
95 container = container,
96 container = container,
96 onOpenSession = { id ->
97 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) } } }
98 navController.navigate(Routes.chat(id))
99 navController.navigate(Routes.chat(id))
99 },
100 },
100 onDisconnect = {
101 onDisconnect = {
101− scope.launch { container.disconnect() }
102+ scope.launch { runCatchingCancellable { container.disconnect() } }
102 navController.popBackStack(Routes.SERVERS, inclusive = false)
103 navController.popBackStack(Routes.SERVERS, inclusive = false)
103 },
104 },
104 onOpenFiles = { navController.navigate(Routes.FILES) },
105 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
22import androidx.compose.ui.Modifier
22import androidx.compose.ui.Modifier
23import androidx.compose.ui.res.stringResource
23import androidx.compose.ui.res.stringResource
24import androidx.compose.ui.semantics.Role
24import androidx.compose.ui.semantics.Role
25+import androidx.compose.ui.semantics.contentDescription
25import androidx.compose.ui.semantics.role
26import androidx.compose.ui.semantics.role
26import androidx.compose.ui.semantics.selected
27import androidx.compose.ui.semantics.selected
27import androidx.compose.ui.semantics.semantics
28import androidx.compose.ui.semantics.semantics
@@ -49,11 +50,12 @@ fun AgentPickerSheet(
49 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
50 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
50 )
51 )
51 if (loading) {
52 if (loading) {
53+ val loadingLabel = stringResource(R.string.loading)
52 Box(
54 Box(
53 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
55 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
54 contentAlignment = Alignment.Center,
56 contentAlignment = Alignment.Center,
55 ) {
57 ) {
56− CircularProgressIndicator()
58+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
57 }
59 }
58 } else if (error && agents.isEmpty()) {
60 } else if (error && agents.isEmpty()) {
59 Text(
61 Text(
@@ -62,7 +64,7 @@ fun AgentPickerSheet(
62 color = MaterialTheme.colorScheme.error,
64 color = MaterialTheme.colorScheme.error,
63 modifier = Modifier
65 modifier = Modifier
64 .fillMaxWidth()
66 .fillMaxWidth()
65− .clickable { onRetry() }
67+ .clickable(role = Role.Button) { onRetry() }
66 .padding(horizontal = 24.dp, vertical = 16.dp),
68 .padding(horizontal = 24.dp, vertical = 16.dp),
67 )
69 )
68 } else if (agents.isEmpty()) {
70 } else if (agents.isEmpty()) {
@@ -79,10 +81,9 @@ fun AgentPickerSheet(
79 modifier = Modifier
81 modifier = Modifier
80 .fillMaxWidth()
82 .fillMaxWidth()
81 .semantics(mergeDescendants = true) {
83 .semantics(mergeDescendants = true) {
82− role = Role.RadioButton
83 if (selected == null) this.selected = true
84 if (selected == null) this.selected = true
84 }
85 }
85− .clickable { onSelect(null); onDismiss() }
86+ .clickable(role = Role.RadioButton) { onSelect(null); onDismiss() }
86 .padding(horizontal = 24.dp, vertical = 12.dp),
87 .padding(horizontal = 24.dp, vertical = 12.dp),
87 ) {
88 ) {
88 Row(verticalAlignment = Alignment.CenterVertically) {
89 Row(verticalAlignment = Alignment.CenterVertically) {
@@ -114,10 +115,9 @@ fun AgentPickerSheet(
114 modifier = Modifier
115 modifier = Modifier
115 .fillMaxWidth()
116 .fillMaxWidth()
116 .semantics(mergeDescendants = true) {
117 .semantics(mergeDescendants = true) {
117− role = Role.RadioButton
118 if (isSelected) this.selected = true
118 if (isSelected) this.selected = true
119 }
119 }
120− .clickable { onSelect(agent); onDismiss() }
120+ .clickable(role = Role.RadioButton) { onSelect(agent); onDismiss() }
121 .padding(horizontal = 24.dp, vertical = 12.dp),
121 .padding(horizontal = 24.dp, vertical = 12.dp),
122 ) {
122 ) {
123 Row(verticalAlignment = Alignment.CenterVertically) {
123 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
58import androidx.compose.runtime.rememberCoroutineScope
58import androidx.compose.runtime.rememberCoroutineScope
59import androidx.compose.runtime.saveable.rememberSaveable
59import androidx.compose.runtime.saveable.rememberSaveable
60import androidx.compose.runtime.setValue
60import androidx.compose.runtime.setValue
61+import androidx.compose.runtime.snapshotFlow
61import androidx.compose.foundation.text.KeyboardActions
62import androidx.compose.foundation.text.KeyboardActions
62import androidx.compose.foundation.text.KeyboardOptions
63import androidx.compose.foundation.text.KeyboardOptions
63import androidx.compose.ui.Alignment
64import androidx.compose.ui.Alignment
@@ -71,6 +72,7 @@ import androidx.compose.ui.input.key.type
71import androidx.compose.ui.platform.LocalContext
72import androidx.compose.ui.platform.LocalContext
72import androidx.compose.ui.platform.LocalHapticFeedback
73import androidx.compose.ui.platform.LocalHapticFeedback
73import androidx.compose.ui.platform.LocalView
74import androidx.compose.ui.platform.LocalView
75+import androidx.compose.ui.platform.testTag
74import androidx.compose.ui.res.stringResource
76import androidx.compose.ui.res.stringResource
75import androidx.compose.ui.semantics.Role
77import androidx.compose.ui.semantics.Role
76import androidx.compose.ui.semantics.contentDescription
78import androidx.compose.ui.semantics.contentDescription
@@ -80,15 +82,15 @@ import androidx.compose.ui.text.input.ImeAction
80import androidx.compose.ui.unit.dp
82import androidx.compose.ui.unit.dp
81import androidx.lifecycle.compose.collectAsStateWithLifecycle
83import androidx.lifecycle.compose.collectAsStateWithLifecycle
82import androidx.lifecycle.viewmodel.compose.viewModel
84import androidx.lifecycle.viewmodel.compose.viewModel
85+import kotlinx.coroutines.flow.collectLatest
83import kotlinx.coroutines.launch
86import kotlinx.coroutines.launch
84import soy.iko.opencode.data.network.EventStreamClient
87import soy.iko.opencode.data.network.EventStreamClient
85−import soy.iko.opencode.data.model.ReasoningPart
86−import soy.iko.opencode.data.model.TextPart
87import soy.iko.opencode.di.AppContainer
88import soy.iko.opencode.di.AppContainer
88import soy.iko.opencode.R
89import soy.iko.opencode.R
89import soy.iko.opencode.ui.components.ConnectionBanner
90import soy.iko.opencode.ui.components.ConnectionBanner
90import soy.iko.opencode.ui.components.toImageContext
91import soy.iko.opencode.ui.components.toImageContext
91import soy.iko.opencode.ui.vmFactory
92import soy.iko.opencode.ui.vmFactory
93+import soy.iko.opencode.util.runCatchingCancellable
92
94
93@OptIn(ExperimentalMaterial3Api::class)
95@OptIn(ExperimentalMaterial3Api::class)
94@Composable
96@Composable
@@ -174,19 +176,17 @@ fun ChatScreen(
174 }
176 }
175
177
176 // Keep the newest content in view as parts stream in — only if already at the bottom.
178 // 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
179+ // A single LaunchedEffect(Unit) + snapshotFlow avoids the rapid cancel/re-launch
178− // only when a brand-new message or part is added.
180+ // churn that keying on lastPartLen (which changes on every streaming token) caused.
179− val lastPartLen = messages.lastOrNull()?.parts?.lastOrNull()?.let { part ->
181+ // collectLatest cancels the in-flight scroll when a newer snapshot arrives, so
180− when (part) {
182+ // streaming stays smooth without dozens of competing animations per second.
181− is TextPart -> part.text.length
183+ LaunchedEffect(Unit) {
182− is ReasoningPart -> part.text.length
183− else -> 0
184− }
185− } ?: 0
186− LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size, lastPartLen) {
184+ snapshotFlow { Triple(messages.size, messages.lastOrNull()?.parts?.size, isPinnedToBottom) }
185+ .collectLatest { (_, _, pinned) ->
187− if (messages.isNotEmpty() && isPinnedToBottom) {
186+ if (messages.isNotEmpty() && pinned) {
188− listState.animateScrollToItem(messages.lastIndex)
187+ listState.animateScrollToItem(messages.lastIndex)
188+ }
189− }
189+ }
190 }
190 }
191 val retryLabel = stringResource(R.string.retry)
191 val retryLabel = stringResource(R.string.retry)
192 LaunchedEffect(error) {
192 LaunchedEffect(error) {
@@ -337,7 +337,7 @@ fun ChatScreen(
337 ExtendedFloatingActionButton(
337 ExtendedFloatingActionButton(
338 onClick = {
338 onClick = {
339 if (messages.isNotEmpty()) {
339 if (messages.isNotEmpty()) {
340− scope.launch { listState.animateScrollToItem(messages.lastIndex) }
340+ scope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
341 }
341 }
342 },
342 },
343 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
343 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
@@ -432,6 +432,7 @@ private fun ChatInputBar(
432 onValueChange = onValueChange,
432 onValueChange = onValueChange,
433 modifier = Modifier
433 modifier = Modifier
434 .weight(1f)
434 .weight(1f)
435+ .testTag("chat_input")
435 .onPreviewKeyEvent { event ->
436 .onPreviewKeyEvent { event ->
436 if (event.type == KeyEventType.KeyDown &&
437 if (event.type == KeyEventType.KeyDown &&
437 event.key == Key.Enter &&
438 event.key == Key.Enter &&
@@ -445,20 +446,21 @@ private fun ChatInputBar(
445 }
446 }
446 },
447 },
447 placeholder = { Text(stringResource(R.string.message_placeholder)) },
448 placeholder = { Text(stringResource(R.string.message_placeholder)) },
449+ label = { Text(stringResource(R.string.message_placeholder)) },
448 enabled = enabled,
450 enabled = enabled,
449 maxLines = 6,
451 maxLines = 6,
450 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
452 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
451 keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
453 keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
452 )
454 )
453 if (running) {
455 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")) {
455 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
457 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
456 }
458 }
457 } else {
459 } else {
458 IconButton(
460 IconButton(
459 onClick = onSend,
461 onClick = onSend,
460 enabled = enabled && value.isNotBlank(),
462 enabled = enabled && value.isNotBlank(),
461− modifier = Modifier.padding(start = 4.dp),
463+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
462 ) {
464 ) {
463 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
465 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
464 }
466 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+2−1
@@ -198,7 +198,8 @@ class ChatViewModel(
198 // Debounce draft persistence so we don't write to disk on every keystroke.
198 // Debounce draft persistence so we don't write to disk on every keystroke.
199 viewModelScope.launch {
199 viewModelScope.launch {
200 _draft.drop(1).debounce(500).collect { text ->
200 _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) }
202 }
203 }
203 }
204 }
204 // Reset per-connection state when the active server changes so stale spinners,
205 // 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
20import androidx.compose.ui.Modifier
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.res.stringResource
21import androidx.compose.ui.res.stringResource
22import androidx.compose.ui.semantics.Role
22import androidx.compose.ui.semantics.Role
23+import androidx.compose.ui.semantics.contentDescription
23import androidx.compose.ui.semantics.role
24import androidx.compose.ui.semantics.role
25+import androidx.compose.ui.semantics.semantics
24import androidx.compose.ui.text.style.TextOverflow
26import androidx.compose.ui.text.style.TextOverflow
25import androidx.compose.ui.unit.dp
27import androidx.compose.ui.unit.dp
26import androidx.compose.foundation.layout.Box
28import androidx.compose.foundation.layout.Box
@@ -45,11 +47,12 @@ fun CommandPickerSheet(
45 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
47 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
46 )
48 )
47 if (loading) {
49 if (loading) {
50+ val loadingLabel = stringResource(R.string.loading)
48 Box(
51 Box(
49 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
52 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
50 contentAlignment = Alignment.Center,
53 contentAlignment = Alignment.Center,
51 ) {
54 ) {
52− CircularProgressIndicator()
55+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
53 }
56 }
54 } else if (error && commands.isEmpty()) {
57 } else if (error && commands.isEmpty()) {
55 Text(
58 Text(
@@ -58,7 +61,7 @@ fun CommandPickerSheet(
58 color = MaterialTheme.colorScheme.error,
61 color = MaterialTheme.colorScheme.error,
59 modifier = Modifier
62 modifier = Modifier
60 .fillMaxWidth()
63 .fillMaxWidth()
61− .clickable { onRetry() }
64+ .clickable(role = Role.Button) { onRetry() }
62 .padding(horizontal = 24.dp, vertical = 16.dp),
65 .padding(horizontal = 24.dp, vertical = 16.dp),
63 )
66 )
64 } else if (commands.isEmpty()) {
67 } 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
22import androidx.compose.ui.Modifier
22import androidx.compose.ui.Modifier
23import androidx.compose.ui.res.stringResource
23import androidx.compose.ui.res.stringResource
24import androidx.compose.ui.semantics.Role
24import androidx.compose.ui.semantics.Role
25+import androidx.compose.ui.semantics.contentDescription
25import androidx.compose.ui.semantics.role
26import androidx.compose.ui.semantics.role
26import androidx.compose.ui.semantics.selected
27import androidx.compose.ui.semantics.selected
27import androidx.compose.ui.semantics.semantics
28import androidx.compose.ui.semantics.semantics
@@ -49,11 +50,12 @@ fun ModelPickerSheet(
49 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
50 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
50 )
51 )
51 if (loading) {
52 if (loading) {
53+ val loadingLabel = stringResource(R.string.loading)
52 Box(
54 Box(
53 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
55 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
54 contentAlignment = Alignment.Center,
56 contentAlignment = Alignment.Center,
55 ) {
57 ) {
56− CircularProgressIndicator()
58+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
57 }
59 }
58 } else if (error && options.isEmpty()) {
60 } else if (error && options.isEmpty()) {
59 Text(
61 Text(
@@ -62,7 +64,7 @@ fun ModelPickerSheet(
62 color = MaterialTheme.colorScheme.error,
64 color = MaterialTheme.colorScheme.error,
63 modifier = Modifier
65 modifier = Modifier
64 .fillMaxWidth()
66 .fillMaxWidth()
65− .clickable { onRetry() }
67+ .clickable(role = Role.Button) { onRetry() }
66 .padding(horizontal = 24.dp, vertical = 16.dp),
68 .padding(horizontal = 24.dp, vertical = 16.dp),
67 )
69 )
68 } else if (options.isEmpty()) {
70 } else if (options.isEmpty()) {
@@ -81,10 +83,9 @@ fun ModelPickerSheet(
81 modifier = Modifier
83 modifier = Modifier
82 .fillMaxWidth()
84 .fillMaxWidth()
83 .semantics(mergeDescendants = true) {
85 .semantics(mergeDescendants = true) {
84− role = Role.RadioButton
85 if (isSelected) this.selected = true
86 if (isSelected) this.selected = true
86 }
87 }
87− .clickable {
88+ .clickable(role = Role.RadioButton) {
88 onSelect(option)
89 onSelect(option)
89 onDismiss()
90 onDismiss()
90 }
91 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+14−4
@@ -5,6 +5,7 @@ import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.defaultMinSize
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.layout.size
@@ -99,6 +100,8 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
99 Column(modifier = modifier.fillMaxWidth()) {
100 Column(modifier = modifier.fillMaxWidth()) {
100 Row(
101 Row(
101 modifier = Modifier
102 modifier = Modifier
103+ .fillMaxWidth()
104+ .defaultMinSize(minHeight = 48.dp)
102 .clickable { expanded = !expanded }
105 .clickable { expanded = !expanded }
103 .padding(vertical = 4.dp)
106 .padding(vertical = 4.dp)
104 .semantics {
107 .semantics {
@@ -108,8 +111,9 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
108 verticalAlignment = Alignment.CenterVertically,
111 verticalAlignment = Alignment.CenterVertically,
109 ) {
112 ) {
110 if (streaming) {
113 if (streaming) {
114+ val thinkingLabel = stringResource(R.string.thinking)
111 CircularProgressIndicator(
115 CircularProgressIndicator(
112− Modifier.size(14.dp),
116+ Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
113 strokeWidth = 2.dp,
117 strokeWidth = 2.dp,
114 color = MaterialTheme.colorScheme.onSurfaceVariant,
118 color = MaterialTheme.colorScheme.onSurfaceVariant,
115 )
119 )
@@ -221,11 +225,13 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
221@Composable
225@Composable
222private fun ToolStatusIcon(state: ToolState) {
226private fun ToolStatusIcon(state: ToolState) {
223 when (state) {
227 when (state) {
224− is ToolPending, is ToolRunning ->
228+ is ToolPending, is ToolRunning -> {
229+ val label = stringResource(R.string.tool_running)
225 CircularProgressIndicator(
230 CircularProgressIndicator(
226− modifier = Modifier.size(16.dp),
231+ modifier = Modifier.size(16.dp).semantics { contentDescription = label },
227 strokeWidth = 2.dp,
232 strokeWidth = 2.dp,
228 )
233 )
234+ }
229 is ToolCompleted ->
235 is ToolCompleted ->
230 Icon(
236 Icon(
231 Icons.Filled.CheckCircle,
237 Icons.Filled.CheckCircle,
@@ -241,7 +247,11 @@ private fun ToolStatusIcon(state: ToolState) {
241 tint = MaterialTheme.colorScheme.error,
247 tint = MaterialTheme.colorScheme.error,
242 )
248 )
243 is ToolUnknown ->
249 is ToolUnknown ->
250+ Icon(
251+ Icons.Filled.Bolt,
252+ contentDescription = stringResource(R.string.tool_unknown),
244− Icon(Icons.Filled.Bolt, contentDescription = null, modifier = Modifier.size(16.dp))
253+ modifier = Modifier.size(16.dp),
254+ )
245 }
255 }
246}
256}
247
257
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+6−1
@@ -13,6 +13,8 @@ import androidx.compose.runtime.Composable
13import androidx.compose.ui.Alignment
13import androidx.compose.ui.Alignment
14import androidx.compose.ui.Modifier
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.res.stringResource
15import androidx.compose.ui.res.stringResource
16+import androidx.compose.ui.semantics.contentDescription
17+import androidx.compose.ui.semantics.semantics
16import androidx.compose.ui.unit.dp
18import androidx.compose.ui.unit.dp
17import soy.iko.opencode.R
19import soy.iko.opencode.R
18import soy.iko.opencode.data.network.EventStreamClient
20import soy.iko.opencode.data.network.EventStreamClient
@@ -42,7 +44,10 @@ fun ConnectionBanner(
42 horizontalArrangement = Arrangement.Center,
44 horizontalArrangement = Arrangement.Center,
43 verticalAlignment = Alignment.CenterVertically,
45 verticalAlignment = Alignment.CenterVertically,
44 ) {
46 ) {
45− CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
47+ CircularProgressIndicator(
48+ Modifier.size(12.dp).semantics { contentDescription = text },
49+ strokeWidth = 2.dp,
50+ )
46 Text(
51 Text(
47 text,
52 text,
48 style = MaterialTheme.typography.labelMedium,
53 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
31import androidx.compose.ui.Modifier
31import androidx.compose.ui.Modifier
32import androidx.compose.ui.draw.clip
32import androidx.compose.ui.draw.clip
33import androidx.compose.ui.platform.LocalContext
33import androidx.compose.ui.platform.LocalContext
34+import androidx.compose.ui.semantics.Role
34import androidx.compose.ui.text.TextStyle
35import androidx.compose.ui.text.TextStyle
35import androidx.compose.ui.text.font.FontFamily
36import androidx.compose.ui.text.font.FontFamily
36import androidx.compose.ui.unit.dp
37import androidx.compose.ui.unit.dp
@@ -81,6 +82,7 @@ fun MarkdownText(
81 modifier = modifier.combinedClickable(
82 modifier = modifier.combinedClickable(
82 onClick = {},
83 onClick = {},
83 onLongClick = { copyToClipboard(context, markdown) },
84 onLongClick = { copyToClipboard(context, markdown) },
85+ role = Role.Button,
84 ),
86 ),
85 components = markdownComponents(
87 components = markdownComponents(
86 codeFence = { CodeWithCopy(it) },
88 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
39import androidx.compose.ui.Alignment
39import androidx.compose.ui.Alignment
40import androidx.compose.ui.Modifier
40import androidx.compose.ui.Modifier
41import androidx.compose.ui.graphics.Color
41import androidx.compose.ui.graphics.Color
42+import androidx.compose.ui.platform.testTag
42import androidx.compose.ui.semantics.Role
43import androidx.compose.ui.semantics.Role
43import androidx.compose.ui.semantics.contentDescription
44import androidx.compose.ui.semantics.contentDescription
44import androidx.compose.ui.semantics.role
45import androidx.compose.ui.semantics.role
@@ -96,8 +97,9 @@ fun FileBrowserScreen(
96 OutlinedTextField(
97 OutlinedTextField(
97 value = state.query,
98 value = state.query,
98 onValueChange = vm::setQuery,
99 onValueChange = vm::setQuery,
99− modifier = Modifier.fillMaxWidth().padding(12.dp),
100+ modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
100 placeholder = { Text(stringResource(R.string.search_files)) },
101 placeholder = { Text(stringResource(R.string.search_files)) },
102+ label = { Text(stringResource(R.string.search_files)) },
101 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
103 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102 singleLine = true,
104 singleLine = true,
103 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
105 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
@@ -105,7 +107,12 @@ fun FileBrowserScreen(
105
107
106 Box(modifier = Modifier.fillMaxSize()) {
108 Box(modifier = Modifier.fillMaxSize()) {
107 when {
109 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+ }
109 state.error != null && !state.isSearching -> Text(
116 state.error != null && !state.isSearching -> Text(
110 state.error ?: "",
117 state.error ?: "",
111 modifier = Modifier.align(Alignment.Center).padding(24.dp),
118 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
29import androidx.compose.ui.Alignment
29import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
30import androidx.compose.ui.Modifier
31import androidx.compose.ui.platform.LocalContext
31import androidx.compose.ui.platform.LocalContext
32+import androidx.compose.ui.semantics.contentDescription
33+import androidx.compose.ui.semantics.semantics
32import androidx.compose.ui.text.font.FontFamily
34import androidx.compose.ui.text.font.FontFamily
33import androidx.compose.ui.text.style.TextOverflow
35import androidx.compose.ui.text.style.TextOverflow
34import androidx.compose.ui.unit.dp
36import androidx.compose.ui.unit.dp
@@ -86,7 +88,12 @@ fun FileViewScreen(
86 ) { padding ->
88 ) { padding ->
87 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
89 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
88 when {
90 when {
91+ state.loading -> {
92+ val loadingLabel = stringResource(R.string.loading)
89− state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
93+ CircularProgressIndicator(
94+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
95+ )
96+ }
90 state.error != null -> Text(
97 state.error != null -> Text(
91 state.error ?: "",
98 state.error ?: "",
92 modifier = Modifier.align(Alignment.Center).padding(24.dp),
99 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
29import androidx.compose.runtime.getValue
29import androidx.compose.runtime.getValue
30import androidx.compose.ui.Alignment
30import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
31import 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
32import androidx.compose.ui.text.input.ImeAction
35import androidx.compose.ui.text.input.ImeAction
33import androidx.compose.ui.text.input.KeyboardType
36import androidx.compose.ui.text.input.KeyboardType
34import androidx.compose.ui.text.input.PasswordVisualTransformation
37import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -64,8 +67,9 @@ fun ServerEditScreen(
64 },
67 },
65 ) { padding ->
68 ) { padding ->
66 if (!state.loaded) {
69 if (!state.loaded) {
70+ val loadingLabel = stringResource(R.string.loading)
67 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
71 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
68− CircularProgressIndicator()
72+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
69 }
73 }
70 } else {
74 } else {
71 Column(
75 Column(
@@ -83,7 +87,7 @@ fun ServerEditScreen(
83 label = { Text(stringResource(R.string.label_optional)) },
87 label = { Text(stringResource(R.string.label_optional)) },
84 singleLine = true,
88 singleLine = true,
85 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
89 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
86− modifier = Modifier.fillMaxWidth(),
90+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
87 )
91 )
88 OutlinedTextField(
92 OutlinedTextField(
89 value = state.baseUrl,
93 value = state.baseUrl,
@@ -92,7 +96,7 @@ fun ServerEditScreen(
92 placeholder = { Text(stringResource(R.string.base_url_hint)) },
96 placeholder = { Text(stringResource(R.string.base_url_hint)) },
93 singleLine = true,
97 singleLine = true,
94 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
98 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
95− modifier = Modifier.fillMaxWidth(),
99+ modifier = Modifier.fillMaxWidth().testTag("server_url"),
96 )
100 )
97 Text(
101 Text(
98 stringResource(R.string.auth_help),
102 stringResource(R.string.auth_help),
@@ -104,7 +108,7 @@ fun ServerEditScreen(
104 label = { Text(stringResource(R.string.username_optional)) },
108 label = { Text(stringResource(R.string.username_optional)) },
105 singleLine = true,
109 singleLine = true,
106 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
110 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
107− modifier = Modifier.fillMaxWidth(),
111+ modifier = Modifier.fillMaxWidth().testTag("server_username"),
108 )
112 )
109 OutlinedTextField(
113 OutlinedTextField(
110 value = state.password,
114 value = state.password,
@@ -114,7 +118,7 @@ fun ServerEditScreen(
114 visualTransformation = PasswordVisualTransformation(),
118 visualTransformation = PasswordVisualTransformation(),
115 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
119 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
116 keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
120 keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
117− modifier = Modifier.fillMaxWidth(),
121+ modifier = Modifier.fillMaxWidth().testTag("server_password"),
118 )
122 )
119 Button(
123 Button(
120 onClick = { vm.save(onDone) },
124 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
43import androidx.compose.ui.Alignment
43import androidx.compose.ui.Alignment
44import androidx.compose.ui.Modifier
44import androidx.compose.ui.Modifier
45import androidx.compose.ui.platform.LocalHapticFeedback
45import 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
46import androidx.compose.ui.text.style.TextOverflow
50import androidx.compose.ui.text.style.TextOverflow
47import androidx.compose.ui.unit.dp
51import androidx.compose.ui.unit.dp
48import androidx.compose.ui.res.stringResource
52import androidx.compose.ui.res.stringResource
@@ -120,7 +124,8 @@ fun ServerListScreen(
120 modifier = Modifier
124 modifier = Modifier
121 .fillMaxWidth()
125 .fillMaxWidth()
122 .animateItem()
126 .animateItem()
127+ .testTag("server_card")
123− .clickable(enabled = connectingId == null) {
128+ .clickable(enabled = connectingId == null, role = Role.Button) {
124 vm.connect(profile, onConnected)
129 vm.connect(profile, onConnected)
125 },
130 },
126 ) {
131 ) {
@@ -158,7 +163,10 @@ fun ServerListScreen(
158 modifier = Modifier.size(22.dp),
163 modifier = Modifier.size(22.dp),
159 )
164 )
160 } else if (connectingId == profile.id) {
165 } else if (connectingId == profile.id) {
166+ val connectingLabel = stringResource(R.string.connecting)
161− CircularProgressIndicator(modifier = Modifier.size(24.dp))
167+ CircularProgressIndicator(
168+ modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
169+ )
162 } else {
170 } else {
163 IconButton(onClick = { onEditProfile(profile.id) }) {
171 IconButton(onClick = { onEditProfile(profile.id) }) {
164 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
172 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
59import androidx.compose.ui.Modifier
59import androidx.compose.ui.Modifier
60import androidx.compose.ui.draw.clip
60import androidx.compose.ui.draw.clip
61import androidx.compose.ui.platform.LocalHapticFeedback
61import androidx.compose.ui.platform.LocalHapticFeedback
62+import androidx.compose.ui.platform.testTag
62import androidx.compose.ui.semantics.Role
63import androidx.compose.ui.semantics.Role
63import androidx.compose.ui.semantics.contentDescription
64import androidx.compose.ui.semantics.contentDescription
64import androidx.compose.ui.semantics.role
65import androidx.compose.ui.semantics.role
@@ -219,8 +220,9 @@ fun SessionListScreen(
219 OutlinedTextField(
220 OutlinedTextField(
220 value = state.query,
221 value = state.query,
221 onValueChange = vm::setQuery,
222 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"),
223 placeholder = { Text(stringResource(R.string.search_sessions)) },
224 placeholder = { Text(stringResource(R.string.search_sessions)) },
225+ label = { Text(stringResource(R.string.search_sessions)) },
224 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
226 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
225 singleLine = true,
227 singleLine = true,
226 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
228 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
@@ -251,7 +253,7 @@ fun SessionListScreen(
251 onClick = { onOpenSession(session.id) },
253 onClick = { onOpenSession(session.id) },
252 onRename = { pendingRenameId = session.id },
254 onRename = { pendingRenameId = session.id },
253 onDelete = { pendingDeleteId = session.id },
255 onDelete = { pendingDeleteId = session.id },
254− modifier = Modifier.animateItem(),
256+ modifier = Modifier.animateItem().testTag("session_card"),
255 )
257 )
256 }
258 }
257 }
259 }
@@ -311,6 +313,7 @@ private fun RenameSessionDialog(
311 onValueChange = onTitleChange,
313 onValueChange = onTitleChange,
312 modifier = Modifier.fillMaxWidth(),
314 modifier = Modifier.fillMaxWidth(),
313 placeholder = { Text(stringResource(R.string.session_title_hint)) },
315 placeholder = { Text(stringResource(R.string.session_title_hint)) },
316+ label = { Text(stringResource(R.string.rename_session)) },
314 singleLine = true,
317 singleLine = true,
315 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
318 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
316 keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm() }),
319 keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm() }),
@@ -412,7 +415,7 @@ private fun SessionCard(
412 color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
415 color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
413 maxLines = 2,
416 maxLines = 2,
414 overflow = TextOverflow.Ellipsis,
417 overflow = TextOverflow.Ellipsis,
415− modifier = Modifier.clickable { onClick() },
418+ modifier = Modifier.clickable(role = Role.Button) { onClick() },
416 )
419 )
417 }
420 }
418 }
421 }
app/src/main/res/values/strings.xml+2
@@ -103,6 +103,8 @@
103 <string name="show_less">Show less</string>
103 <string name="show_less">Show less</string>
104 <string name="tool_completed">Completed</string>
104 <string name="tool_completed">Completed</string>
105 <string name="tool_error">Error</string>
105 <string name="tool_error">Error</string>
106+ <string name="tool_running">Running</string>
107+ <string name="tool_unknown">Unknown tool state</string>
106 <string name="state_expanded">Expanded</string>
108 <string name="state_expanded">Expanded</string>
107 <string name="state_collapsed">Collapsed</string>
109 <string name="state_collapsed">Collapsed</string>
108
110