git.iko.soy/opencode-android
on master help sign in
Lazily render large file views and diffs, keep chat scrolled to streaming output, and surface session list errors via snackbar instead of hiding the list
commitfbd824e722872b607fb8334c6d5f8507a2e0ea35 authoriko <mail@iko.soy> dateMon 29 Jun 2026, 12:26 (3 months ago) parent17bd7b0
5 files changed+80−40
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+12−1 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+15−17 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+8−15 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+13−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+32−6
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+12−1
@@ -75,6 +75,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
7575import androidx.lifecycle.viewmodel.compose.viewModel
7676import kotlinx.coroutines.launch
7777import soy.iko.opencode.data.network.EventStreamClient
78+import soy.iko.opencode.data.model.ReasoningPart
79+import soy.iko.opencode.data.model.TextPart
7880import soy.iko.opencode.di.AppContainer
7981import soy.iko.opencode.R
8082import soy.iko.opencode.ui.vmFactory
@@ -120,7 +122,16 @@ fun ChatScreen(
120122 }
121123
122124 // Keep the newest content in view as parts stream in — only if already at the bottom.
123− LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
125+ // Include the growing text length so we follow streaming within a single part, not
126+ // only when a brand-new message or part is added.
127+ val lastPartLen = messages.lastOrNull()?.parts?.lastOrNull()?.let { part ->
128+ when (part) {
129+ is TextPart -> part.text.length
130+ is ReasoningPart -> part.text.length
131+ else -> 0
132+ }
133+ } ?: 0
134+ LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size, lastPartLen) {
124135 if (messages.isNotEmpty() && isPinnedToBottom) {
125136 listState.animateScrollToItem(messages.lastIndex)
126137 }
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+15−17
@@ -3,14 +3,15 @@ package soy.iko.opencode.ui.components
33import androidx.compose.foundation.background
44import androidx.compose.foundation.horizontalScroll
55import androidx.compose.foundation.layout.Arrangement
6−import androidx.compose.foundation.layout.Column
76import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.Spacer
88import androidx.compose.foundation.layout.fillMaxWidth
99import androidx.compose.foundation.layout.padding
1010import androidx.compose.foundation.layout.width
11+import androidx.compose.foundation.lazy.LazyColumn
12+import androidx.compose.foundation.lazy.itemsIndexed
1113import androidx.compose.foundation.rememberScrollState
1214import androidx.compose.foundation.shape.RoundedCornerShape
13−import androidx.compose.foundation.verticalScroll
1415import androidx.compose.material.icons.Icons
1516import androidx.compose.material.icons.filled.ContentCopy
1617import androidx.compose.material3.Icon
@@ -82,28 +83,24 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
8283 val addText = MaterialTheme.colorScheme.primary
8384 val removeText = MaterialTheme.colorScheme.error
8485 val context = LocalContext.current
86+ val hScrollState = rememberScrollState()
8587
86− Column(
88+ LazyColumn(
8789 modifier = modifier
8890 .fillMaxWidth()
8991 .padding(vertical = 4.dp)
9092 .clip(RoundedCornerShape(8.dp))
91− .background(MaterialTheme.colorScheme.surfaceVariant)
92− .padding(top = 2.dp, end = 4.dp),
93+ .background(MaterialTheme.colorScheme.surfaceVariant),
9394 ) {
94− Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
95− IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
96− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
95+ item(key = "__copy") {
96+ Row(modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp), horizontalArrangement = Arrangement.End) {
97+ IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
98+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
99+ }
97100 }
98101 }
99− Column(
100− modifier = Modifier
101− .horizontalScroll(rememberScrollState())
102− .verticalScroll(rememberScrollState())
103− .padding(horizontal = 10.dp)
104− .padding(bottom = 10.dp),
105− ) {
106− for (line in lines) {
102+ itemsIndexed(lines, key = { index, _ -> index }) { _, line ->
103+ Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
107104 when (line) {
108105 is DiffLine.Hunk -> Text(
109106 line.text,
@@ -125,13 +122,14 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
125122 }
126123 }
127124 }
125+ item(key = "__spacer") { Spacer(Modifier.padding(bottom = 10.dp)) }
128126 }
129127}
130128
131129@Composable
132130private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
133131 Row(
134− modifier = Modifier.fillMaxWidth().background(bg),
132+ modifier = Modifier.background(bg),
135133 ) {
136134 Text(
137135 prefix,
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+8−15
@@ -3,15 +3,13 @@ package soy.iko.opencode.ui.file
33import android.content.Intent
44import androidx.compose.foundation.horizontalScroll
55import androidx.compose.foundation.layout.Box
6−import androidx.compose.foundation.layout.Column
76import androidx.compose.foundation.layout.Row
87import androidx.compose.foundation.layout.fillMaxSize
98import androidx.compose.foundation.layout.padding
109import androidx.compose.foundation.layout.width
1110import androidx.compose.foundation.lazy.LazyColumn
12−import androidx.compose.foundation.lazy.items
11+import androidx.compose.foundation.lazy.itemsIndexed
1312import androidx.compose.foundation.rememberScrollState
14−import androidx.compose.foundation.verticalScroll
1513import androidx.compose.material.icons.Icons
1614import androidx.compose.material.icons.automirrored.filled.ArrowBack
1715import androidx.compose.material.icons.filled.ContentCopy
@@ -100,7 +98,6 @@ fun FileViewScreen(
10098 )
10199 else -> {
102100 val content = state.content
103− val scrollState = rememberScrollState()
104101 if (content?.diff != null && content.diff.isNotBlank()) {
105102 DiffView(
106103 diff = content.diff,
@@ -121,28 +118,24 @@ fun FileViewScreen(
121118 val gutterWidth = remember(lines.size) {
122119 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
123120 }
124− Row(
121+ val hScrollState = rememberScrollState()
122+ LazyColumn(
125123 modifier = Modifier
126124 .fillMaxSize()
127− .verticalScroll(scrollState)
128− .horizontalScroll(rememberScrollState())
129125 .padding(8.dp),
130126 ) {
131− // Line-number gutter
132− Column(modifier = Modifier.width(gutterWidth)) {
133− lines.forEachIndexed { i, _ ->
127+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
128+ Row(modifier = Modifier.horizontalScroll(hScrollState)) {
134129 Text(
135− "${i + 1}",
130+ "${index + 1}",
131+ modifier = Modifier.width(gutterWidth),
136132 style = MaterialTheme.typography.bodySmall,
137133 fontFamily = FontFamily.Monospace,
138134 color = MaterialTheme.colorScheme.onSurfaceVariant,
139135 )
140− }
141− }
142− Column(modifier = Modifier.padding(start = 8.dp)) {
143− lines.forEach { line ->
144136 Text(
145137 line,
138+ modifier = Modifier.padding(start = 8.dp),
146139 style = MaterialTheme.typography.bodySmall,
147140 fontFamily = FontFamily.Monospace,
148141 )
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+13−1
@@ -39,10 +39,13 @@ import androidx.compose.material3.IconButton
3939import androidx.compose.material3.MaterialTheme
4040import androidx.compose.material3.OutlinedTextField
4141import androidx.compose.material3.Scaffold
42+import androidx.compose.material3.SnackbarHost
43+import androidx.compose.material3.SnackbarHostState
4244import androidx.compose.material3.Text
4345import androidx.compose.material3.TextButton
4446import androidx.compose.material3.TopAppBar
4547import androidx.compose.runtime.Composable
48+import androidx.compose.runtime.LaunchedEffect
4649import androidx.compose.runtime.getValue
4750import androidx.compose.runtime.mutableStateOf
4851import androidx.compose.runtime.remember
@@ -76,14 +79,22 @@ fun SessionListScreen(
7679) {
7780 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
7881 val state by vm.state.collectAsStateWithLifecycle()
82+ val transientError by vm.transientError.collectAsStateWithLifecycle()
7983 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
8084 val profiles by vm.profiles.collectAsStateWithLifecycle()
8185 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
8286 val haptics = LocalHapticFeedback.current
87+ val snackbar = remember { SnackbarHostState() }
8388 var showServerMenu by remember { mutableStateOf(false) }
8489 var pendingDelete by remember { mutableStateOf<Session?>(null) }
8590 var pendingRename by remember { mutableStateOf<Session?>(null) }
8691
92+ LaunchedEffect(transientError) {
93+ val msg = transientError ?: return@LaunchedEffect
94+ snackbar.showSnackbar(msg)
95+ vm.clearTransientError()
96+ }
97+
8798 Scaffold(
8899 topBar = {
89100 TopAppBar(
@@ -153,6 +164,7 @@ fun SessionListScreen(
153164 },
154165 )
155166 },
167+ snackbarHost = { SnackbarHost(snackbar) },
156168 floatingActionButton = {
157169 ExtendedFloatingActionButton(
158170 onClick = { vm.createSession(onCreated = onOpenSession) },
@@ -164,7 +176,7 @@ fun SessionListScreen(
164176 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
165177 when {
166178 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
167− state.error != null -> Text(
179+ state.sessions.isEmpty() && state.error != null -> Text(
168180 state.error ?: "",
169181 modifier = Modifier.align(Alignment.Center).padding(24.dp),
170182 color = MaterialTheme.colorScheme.error,
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+32−6
@@ -58,6 +58,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
5858 private val _state = MutableStateFlow(SessionListState())
5959 val state: StateFlow<SessionListState> = _state.asStateFlow()
6060
61+ /** Transient errors (failed create/delete/rename) surfaced as a snackbar, not by hiding the list. */
62+ private val _transientError = MutableStateFlow<String?>(null)
63+ val transientError: StateFlow<String?> = _transientError.asStateFlow()
64+ fun clearTransientError() { _transientError.value = null }
65+
6166 private var previewJob: Job? = null
6267
6368 private val activeProfileId: String?
@@ -72,7 +77,12 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
7277 fun refresh() {
7378 val conn = container.activeConnection.value
7479 if (conn == null) {
75− _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
80+ if (_state.value.sessions.isEmpty()) {
81+ _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
82+ } else {
83+ _state.value = _state.value.copy(loading = false)
84+ _transientError.value = container.string(R.string.not_connected)
85+ }
7686 return
7787 }
7888 _serverLabel.value = conn.profile.displayLabel
@@ -81,10 +91,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
8191 runCatching { conn.repository.listSessions() }
8292 .onSuccess { list ->
8393 val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
84− _state.value = _state.value.copy(sessions = sorted, loading = false)
94+ _state.value = _state.value.copy(sessions = sorted, loading = false, error = null)
8595 loadPreviews(sorted)
8696 }
87− .onFailure { _state.value = SessionListState(loading = false, error = container.friendlyError(it)) }
97+ .onFailure {
98+ if (_state.value.sessions.isNotEmpty()) {
99+ _state.value = _state.value.copy(loading = false)
100+ _transientError.value = container.friendlyError(it)
101+ } else {
102+ _state.value = SessionListState(loading = false, error = container.friendlyError(it))
103+ }
104+ }
88105 }
89106 }
90107
@@ -120,7 +137,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
120137 viewModelScope.launch {
121138 runCatching { conn.repository.createSession() }
122139 .onSuccess { onCreated(it.id); refresh() }
123− .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it)) }
140+ .onFailure {
141+ _state.value = _state.value.copy(error = null)
142+ _transientError.value = container.friendlyError(it)
143+ }
124144 }
125145 }
126146
@@ -129,7 +149,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
129149 viewModelScope.launch {
130150 runCatching { conn.repository.deleteSession(session.id) }
131151 .onSuccess { refresh() }
132− .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it)) }
152+ .onFailure {
153+ _state.value = _state.value.copy(error = null)
154+ _transientError.value = container.friendlyError(it)
155+ }
133156 }
134157 }
135158
@@ -140,7 +163,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
140163 viewModelScope.launch {
141164 runCatching { conn.api.updateSession(session.id, title) }
142165 .onSuccess { refresh() }
143− .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it)) }
166+ .onFailure {
167+ _state.value = _state.value.copy(error = null)
168+ _transientError.value = container.friendlyError(it)
169+ }
144170 }
145171 }
146172