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
fbd824eiko
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+12−1
@@ -75,6 +75,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
75import androidx.lifecycle.viewmodel.compose.viewModel
75import androidx.lifecycle.viewmodel.compose.viewModel
76import kotlinx.coroutines.launch
76import kotlinx.coroutines.launch
77import soy.iko.opencode.data.network.EventStreamClient
77import soy.iko.opencode.data.network.EventStreamClient
78+import soy.iko.opencode.data.model.ReasoningPart
79+import soy.iko.opencode.data.model.TextPart
78import soy.iko.opencode.di.AppContainer
80import soy.iko.opencode.di.AppContainer
79import soy.iko.opencode.R
81import soy.iko.opencode.R
80import soy.iko.opencode.ui.vmFactory
82import soy.iko.opencode.ui.vmFactory
@@ -120,7 +122,16 @@ fun ChatScreen(
120 }
122 }
121
123
122 // Keep the newest content in view as parts stream in — only if already at the bottom.
124 // Keep the newest content in view as parts stream in — only if already at the bottom.
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
123− LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
134+ LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size, lastPartLen) {
124 if (messages.isNotEmpty() && isPinnedToBottom) {
135 if (messages.isNotEmpty() && isPinnedToBottom) {
125 listState.animateScrollToItem(messages.lastIndex)
136 listState.animateScrollToItem(messages.lastIndex)
126 }
137 }
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+15−17
@@ -3,14 +3,15 @@ package soy.iko.opencode.ui.components
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4import androidx.compose.foundation.horizontalScroll
4import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
6−import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.width
10import androidx.compose.foundation.layout.width
11+import androidx.compose.foundation.lazy.LazyColumn
12+import androidx.compose.foundation.lazy.itemsIndexed
11import androidx.compose.foundation.rememberScrollState
13import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.shape.RoundedCornerShape
14import androidx.compose.foundation.shape.RoundedCornerShape
13−import androidx.compose.foundation.verticalScroll
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.filled.ContentCopy
16import androidx.compose.material.icons.filled.ContentCopy
16import androidx.compose.material3.Icon
17import androidx.compose.material3.Icon
@@ -82,28 +83,24 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
82 val addText = MaterialTheme.colorScheme.primary
83 val addText = MaterialTheme.colorScheme.primary
83 val removeText = MaterialTheme.colorScheme.error
84 val removeText = MaterialTheme.colorScheme.error
84 val context = LocalContext.current
85 val context = LocalContext.current
86+ val hScrollState = rememberScrollState()
85
87
86− Column(
88+ LazyColumn(
87 modifier = modifier
89 modifier = modifier
88 .fillMaxWidth()
90 .fillMaxWidth()
89 .padding(vertical = 4.dp)
91 .padding(vertical = 4.dp)
90 .clip(RoundedCornerShape(8.dp))
92 .clip(RoundedCornerShape(8.dp))
91− .background(MaterialTheme.colorScheme.surfaceVariant)
93+ .background(MaterialTheme.colorScheme.surfaceVariant),
92− .padding(top = 2.dp, end = 4.dp),
93 ) {
94 ) {
95+ item(key = "__copy") {
94− Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
96+ Row(modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp), horizontalArrangement = Arrangement.End) {
95− IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
97+ IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
96− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
98+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
99+ }
97 }
100 }
98 }
101 }
99− Column(
102+ itemsIndexed(lines, key = { index, _ -> index }) { _, line ->
100− modifier = Modifier
101− .horizontalScroll(rememberScrollState())
102− .verticalScroll(rememberScrollState())
103− .padding(horizontal = 10.dp)
103+ Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
104− .padding(bottom = 10.dp),
105− ) {
106− for (line in lines) {
107 when (line) {
104 when (line) {
108 is DiffLine.Hunk -> Text(
105 is DiffLine.Hunk -> Text(
109 line.text,
106 line.text,
@@ -125,13 +122,14 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
125 }
122 }
126 }
123 }
127 }
124 }
125+ item(key = "__spacer") { Spacer(Modifier.padding(bottom = 10.dp)) }
128 }
126 }
129}
127}
130
128
131@Composable
129@Composable
132private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
130private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
133 Row(
131 Row(
134− modifier = Modifier.fillMaxWidth().background(bg),
132+ modifier = Modifier.background(bg),
135 ) {
133 ) {
136 Text(
134 Text(
137 prefix,
135 prefix,
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+8−15
@@ -3,15 +3,13 @@ package soy.iko.opencode.ui.file
3import android.content.Intent
3import android.content.Intent
4import androidx.compose.foundation.horizontalScroll
4import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
6−import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.width
9import androidx.compose.foundation.layout.width
11import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
12−import androidx.compose.foundation.lazy.items
11+import androidx.compose.foundation.lazy.itemsIndexed
13import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.rememberScrollState
14−import androidx.compose.foundation.verticalScroll
15import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
14import androidx.compose.material.icons.automirrored.filled.ArrowBack
17import androidx.compose.material.icons.filled.ContentCopy
15import androidx.compose.material.icons.filled.ContentCopy
@@ -100,7 +98,6 @@ fun FileViewScreen(
100 )
98 )
101 else -> {
99 else -> {
102 val content = state.content
100 val content = state.content
103− val scrollState = rememberScrollState()
104 if (content?.diff != null && content.diff.isNotBlank()) {
101 if (content?.diff != null && content.diff.isNotBlank()) {
105 DiffView(
102 DiffView(
106 diff = content.diff,
103 diff = content.diff,
@@ -121,28 +118,24 @@ fun FileViewScreen(
121 val gutterWidth = remember(lines.size) {
118 val gutterWidth = remember(lines.size) {
122 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
119 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
123 }
120 }
124− Row(
121+ val hScrollState = rememberScrollState()
122+ LazyColumn(
125 modifier = Modifier
123 modifier = Modifier
126 .fillMaxSize()
124 .fillMaxSize()
127− .verticalScroll(scrollState)
128− .horizontalScroll(rememberScrollState())
129 .padding(8.dp),
125 .padding(8.dp),
130 ) {
126 ) {
131− // Line-number gutter
127+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
132− Column(modifier = Modifier.width(gutterWidth)) {
128+ Row(modifier = Modifier.horizontalScroll(hScrollState)) {
133− lines.forEachIndexed { i, _ ->
134 Text(
129 Text(
135− "${i + 1}",
130+ "${index + 1}",
131+ modifier = Modifier.width(gutterWidth),
136 style = MaterialTheme.typography.bodySmall,
132 style = MaterialTheme.typography.bodySmall,
137 fontFamily = FontFamily.Monospace,
133 fontFamily = FontFamily.Monospace,
138 color = MaterialTheme.colorScheme.onSurfaceVariant,
134 color = MaterialTheme.colorScheme.onSurfaceVariant,
139 )
135 )
140− }
141− }
142− Column(modifier = Modifier.padding(start = 8.dp)) {
143− lines.forEach { line ->
144 Text(
136 Text(
145 line,
137 line,
138+ modifier = Modifier.padding(start = 8.dp),
146 style = MaterialTheme.typography.bodySmall,
139 style = MaterialTheme.typography.bodySmall,
147 fontFamily = FontFamily.Monospace,
140 fontFamily = FontFamily.Monospace,
148 )
141 )
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+13−1
@@ -39,10 +39,13 @@ import androidx.compose.material3.IconButton
39import androidx.compose.material3.MaterialTheme
39import androidx.compose.material3.MaterialTheme
40import androidx.compose.material3.OutlinedTextField
40import androidx.compose.material3.OutlinedTextField
41import androidx.compose.material3.Scaffold
41import androidx.compose.material3.Scaffold
42+import androidx.compose.material3.SnackbarHost
43+import androidx.compose.material3.SnackbarHostState
42import androidx.compose.material3.Text
44import androidx.compose.material3.Text
43import androidx.compose.material3.TextButton
45import androidx.compose.material3.TextButton
44import androidx.compose.material3.TopAppBar
46import androidx.compose.material3.TopAppBar
45import androidx.compose.runtime.Composable
47import androidx.compose.runtime.Composable
48+import androidx.compose.runtime.LaunchedEffect
46import androidx.compose.runtime.getValue
49import androidx.compose.runtime.getValue
47import androidx.compose.runtime.mutableStateOf
50import androidx.compose.runtime.mutableStateOf
48import androidx.compose.runtime.remember
51import androidx.compose.runtime.remember
@@ -76,14 +79,22 @@ fun SessionListScreen(
76) {
79) {
77 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
80 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
78 val state by vm.state.collectAsStateWithLifecycle()
81 val state by vm.state.collectAsStateWithLifecycle()
82+ val transientError by vm.transientError.collectAsStateWithLifecycle()
79 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
83 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
80 val profiles by vm.profiles.collectAsStateWithLifecycle()
84 val profiles by vm.profiles.collectAsStateWithLifecycle()
81 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
85 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
82 val haptics = LocalHapticFeedback.current
86 val haptics = LocalHapticFeedback.current
87+ val snackbar = remember { SnackbarHostState() }
83 var showServerMenu by remember { mutableStateOf(false) }
88 var showServerMenu by remember { mutableStateOf(false) }
84 var pendingDelete by remember { mutableStateOf<Session?>(null) }
89 var pendingDelete by remember { mutableStateOf<Session?>(null) }
85 var pendingRename by remember { mutableStateOf<Session?>(null) }
90 var pendingRename by remember { mutableStateOf<Session?>(null) }
86
91
92+ LaunchedEffect(transientError) {
93+ val msg = transientError ?: return@LaunchedEffect
94+ snackbar.showSnackbar(msg)
95+ vm.clearTransientError()
96+ }
97+
87 Scaffold(
98 Scaffold(
88 topBar = {
99 topBar = {
89 TopAppBar(
100 TopAppBar(
@@ -153,6 +164,7 @@ fun SessionListScreen(
153 },
164 },
154 )
165 )
155 },
166 },
167+ snackbarHost = { SnackbarHost(snackbar) },
156 floatingActionButton = {
168 floatingActionButton = {
157 ExtendedFloatingActionButton(
169 ExtendedFloatingActionButton(
158 onClick = { vm.createSession(onCreated = onOpenSession) },
170 onClick = { vm.createSession(onCreated = onOpenSession) },
@@ -164,7 +176,7 @@ fun SessionListScreen(
164 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
176 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
165 when {
177 when {
166 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
178 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
167− state.error != null -> Text(
179+ state.sessions.isEmpty() && state.error != null -> Text(
168 state.error ?: "",
180 state.error ?: "",
169 modifier = Modifier.align(Alignment.Center).padding(24.dp),
181 modifier = Modifier.align(Alignment.Center).padding(24.dp),
170 color = MaterialTheme.colorScheme.error,
182 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() {
58 private val _state = MutableStateFlow(SessionListState())
58 private val _state = MutableStateFlow(SessionListState())
59 val state: StateFlow<SessionListState> = _state.asStateFlow()
59 val state: StateFlow<SessionListState> = _state.asStateFlow()
60
60
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+
61 private var previewJob: Job? = null
66 private var previewJob: Job? = null
62
67
63 private val activeProfileId: String?
68 private val activeProfileId: String?
@@ -72,7 +77,12 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
72 fun refresh() {
77 fun refresh() {
73 val conn = container.activeConnection.value
78 val conn = container.activeConnection.value
74 if (conn == null) {
79 if (conn == null) {
80+ if (_state.value.sessions.isEmpty()) {
75− _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
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+ }
76 return
86 return
77 }
87 }
78 _serverLabel.value = conn.profile.displayLabel
88 _serverLabel.value = conn.profile.displayLabel
@@ -81,10 +91,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
81 runCatching { conn.repository.listSessions() }
91 runCatching { conn.repository.listSessions() }
82 .onSuccess { list ->
92 .onSuccess { list ->
83 val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
93 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)
85 loadPreviews(sorted)
95 loadPreviews(sorted)
86 }
96 }
97+ .onFailure {
98+ if (_state.value.sessions.isNotEmpty()) {
99+ _state.value = _state.value.copy(loading = false)
100+ _transientError.value = container.friendlyError(it)
101+ } else {
87− .onFailure { _state.value = SessionListState(loading = false, error = container.friendlyError(it)) }
102+ _state.value = SessionListState(loading = false, error = container.friendlyError(it))
103+ }
104+ }
88 }
105 }
89 }
106 }
90
107
@@ -120,7 +137,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
120 viewModelScope.launch {
137 viewModelScope.launch {
121 runCatching { conn.repository.createSession() }
138 runCatching { conn.repository.createSession() }
122 .onSuccess { onCreated(it.id); refresh() }
139 .onSuccess { onCreated(it.id); refresh() }
140+ .onFailure {
123− .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it)) }
141+ _state.value = _state.value.copy(error = null)
142+ _transientError.value = container.friendlyError(it)
143+ }
124 }
144 }
125 }
145 }
126
146
@@ -129,7 +149,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
129 viewModelScope.launch {
149 viewModelScope.launch {
130 runCatching { conn.repository.deleteSession(session.id) }
150 runCatching { conn.repository.deleteSession(session.id) }
131 .onSuccess { refresh() }
151 .onSuccess { refresh() }
152+ .onFailure {
132− .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it)) }
153+ _state.value = _state.value.copy(error = null)
154+ _transientError.value = container.friendlyError(it)
155+ }
133 }
156 }
134 }
157 }
135
158
@@ -140,7 +163,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
140 viewModelScope.launch {
163 viewModelScope.launch {
141 runCatching { conn.api.updateSession(session.id, title) }
164 runCatching { conn.api.updateSession(session.id, title) }
142 .onSuccess { refresh() }
165 .onSuccess { refresh() }
166+ .onFailure {
143− .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it)) }
167+ _state.value = _state.value.copy(error = null)
168+ _transientError.value = container.friendlyError(it)
169+ }
144 }
170 }
145 }
171 }
146
172