git.iko.soy/opencode-android
on master help sign in
Fix streaming auto-scroll not firing on text part updates and add loading indicator to crash report dialog
f2ff2ebiko
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+13−2
@@ -85,6 +85,7 @@ import kotlinx.coroutines.Dispatchers
85import kotlinx.coroutines.flow.collectLatest
85import kotlinx.coroutines.flow.collectLatest
86import kotlinx.coroutines.launch
86import kotlinx.coroutines.launch
87import kotlinx.coroutines.withContext
87import kotlinx.coroutines.withContext
88+import soy.iko.opencode.data.model.TextPart
88import soy.iko.opencode.data.network.EventStreamClient
89import soy.iko.opencode.data.network.EventStreamClient
89import soy.iko.opencode.di.AppContainer
90import soy.iko.opencode.di.AppContainer
90import soy.iko.opencode.R
91import soy.iko.opencode.R
@@ -188,9 +189,19 @@ fun ChatScreen(
188 // churn that keying on lastPartLen (which changes on every streaming token) caused.
189 // churn that keying on lastPartLen (which changes on every streaming token) caused.
189 // collectLatest cancels the in-flight scroll when a newer snapshot arrives, so
190 // collectLatest cancels the in-flight scroll when a newer snapshot arrives, so
190 // streaming stays smooth without dozens of competing animations per second.
191 // streaming stays smooth without dozens of competing animations per second.
192+ //
193+ // The second Triple component tracks the total text length of the last message's
194+ // parts. When a streaming token updates an existing TextPart (same part ID, longer
195+ // text), messages.size and parts.size don't change — without the text length, the
196+ // snapshotFlow wouldn't emit and the view wouldn't auto-scroll to follow the stream.
191 LaunchedEffect(Unit) {
197 LaunchedEffect(Unit) {
192− snapshotFlow { Triple(messages.size, messages.lastOrNull()?.parts?.size, isPinnedToBottom) }
198+ snapshotFlow {
199+ Triple(
200+ messages.size,
201+ messages.lastOrNull()?.parts?.sumOf { (it as? TextPart)?.text?.length ?: 0 } ?: 0,
202+ isPinnedToBottom,
203+ )
193− .collectLatest { (_, _, pinned) ->
204+ }.collectLatest { (_, _, pinned) ->
194 if (messages.isNotEmpty() && pinned) {
205 if (messages.isNotEmpty() && pinned) {
195 listState.animateScrollToItem(messages.lastIndex)
206 listState.animateScrollToItem(messages.lastIndex)
196 }
207 }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+18−5
@@ -4,6 +4,7 @@ import android.content.Intent
4import android.util.Log
4import android.util.Log
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
7+import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Spacer
10import androidx.compose.foundation.layout.Spacer
@@ -19,6 +20,7 @@ import androidx.compose.material.icons.filled.BugReport
19import androidx.compose.material.icons.filled.DeleteSweep
20import androidx.compose.material.icons.filled.DeleteSweep
20import androidx.compose.material.icons.filled.Share
21import androidx.compose.material.icons.filled.Share
21import androidx.compose.material3.AlertDialog
22import androidx.compose.material3.AlertDialog
23+import androidx.compose.material3.CircularProgressIndicator
22import androidx.compose.material3.ExperimentalMaterial3Api
24import androidx.compose.material3.ExperimentalMaterial3Api
23import androidx.compose.material3.HorizontalDivider
25import androidx.compose.material3.HorizontalDivider
24import androidx.compose.material3.Icon
26import androidx.compose.material3.Icon
@@ -44,6 +46,7 @@ import androidx.compose.ui.Modifier
44import androidx.compose.ui.platform.LocalContext
46import androidx.compose.ui.platform.LocalContext
45import androidx.compose.ui.res.stringResource
47import androidx.compose.ui.res.stringResource
46import androidx.compose.ui.semantics.Role
48import androidx.compose.ui.semantics.Role
49+import androidx.compose.ui.semantics.contentDescription
47import androidx.compose.ui.semantics.heading
50import androidx.compose.ui.semantics.heading
48import androidx.compose.ui.semantics.role
51import androidx.compose.ui.semantics.role
49import androidx.compose.ui.semantics.semantics
52import androidx.compose.ui.semantics.semantics
@@ -189,11 +192,21 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
189 },
192 },
190 title = { Text(stringResource(R.string.crash_report)) },
193 title = { Text(stringResource(R.string.crash_report)) },
191 text = {
194 text = {
195+ if (content != null) {
192− Text(
196+ Text(
193− content ?: "",
197+ content,
194− style = MaterialTheme.typography.bodySmall,
198+ style = MaterialTheme.typography.bodySmall,
195− modifier = Modifier.fillMaxWidth(),
199+ modifier = Modifier.fillMaxWidth(),
196− )
200+ )
201+ } else {
202+ val loadingLabel = stringResource(R.string.loading)
203+ Box(
204+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
205+ contentAlignment = Alignment.Center,
206+ ) {
207+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
208+ }
209+ }
197 },
210 },
198 )
211 )
199 }
212 }