git.iko.soy/opencode-android
on master help sign in
Fail fast on non-retryable client errors, coalesce bursty preview loads, auto-refresh relative timestamps, and clean up orphaned session drafts
commit80ddf731df735a910d85b4e07abafea821f16580 authoriko <mail@iko.soy> dateMon 29 Jun 2026, 14:02 (3 months ago) parent0adc7fd
7 files changed+100−43
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+6−2 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+5 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+2−2 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+25 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+33−28 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+2−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+27−9
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+6−2
@@ -18,6 +18,7 @@ import soy.iko.opencode.data.model.Session
1818import soy.iko.opencode.data.model.UpdateSessionRequest
1919import io.ktor.client.HttpClient
2020import io.ktor.client.call.body
21+import io.ktor.client.plugins.ClientRequestException
2122import io.ktor.client.request.delete
2223import io.ktor.client.request.get
2324import io.ktor.client.request.parameter
@@ -142,8 +143,10 @@ class OpencodeApiClient(private val client: HttpClient) {
142143
143144/**
144145 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
145− * Cancellation exceptions are re-thrown immediately. Non-2xx HTTP responses throw
146− * (because [HttpClientFactory] sets `expectSuccess = true`) and are retried.
146+ * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
147+ * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
148+ * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
149+ * server errors are retried.
147150 */
148151private suspend fun <T> withRetry(
149152 maxAttempts: Int = 3,
@@ -158,6 +161,7 @@ private suspend fun <T> withRetry(
158161 throw c
159162 } catch (t: Throwable) {
160163 lastError = t
164+ if (t is ClientRequestException) throw t
161165 if (attempt < maxAttempts) delay(initialDelayMs * (1 shl (attempt - 1)))
162166 }
163167 }
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+5
@@ -19,4 +19,9 @@ class DraftStore(context: Context) {
1919 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
2020 }.apply()
2121 }
22+
23+ /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
24+ fun remove(sessionId: String) {
25+ prefs.edit().remove(sessionId).apply()
26+ }
2227}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+2−2
@@ -19,7 +19,7 @@ import soy.iko.opencode.data.model.MessageWithParts
1919import soy.iko.opencode.data.model.Tokens
2020import soy.iko.opencode.data.model.UserMessage
2121import soy.iko.opencode.ui.components.ImageLoadContext
22−import soy.iko.opencode.ui.components.relativeTime
22+import soy.iko.opencode.ui.components.rememberRelativeTime
2323
2424/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
2525@Composable
@@ -92,7 +92,7 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageC
9292
9393@Composable
9494private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
95− val t = relativeTime(info.time?.created)
95+ val t = rememberRelativeTime(info.time?.created)
9696 if (t.isNotEmpty()) {
9797 Text(
9898 t,
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+25
@@ -1,5 +1,12 @@
11package soy.iko.opencode.ui.components
22
3+import androidx.compose.runtime.Composable
4+import androidx.compose.runtime.LaunchedEffect
5+import androidx.compose.runtime.getValue
6+import androidx.compose.runtime.mutableLongStateOf
7+import androidx.compose.runtime.remember
8+import androidx.compose.runtime.setValue
9+import kotlinx.coroutines.delay
310import java.util.concurrent.TimeUnit
411
512/** Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d"). */
@@ -20,3 +27,21 @@ fun relativeTime(epochMillis: Long?): String {
2027 if (months < 12) return "${months}mo"
2128 return "${days / 365}y"
2229}
30+
31+/**
32+ * Returns a relative-time string for [epochMillis] that auto-refreshes so the label
33+ * doesn't go stale (e.g. "3m" → "4m" → "5m" while the screen is visible). Re-evaluates
34+ * every [intervalMs] (default 30 s — coarse enough for battery, fine enough for the
35+ * smallest unit we show, which is minutes).
36+ */
37+@Composable
38+fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
39+ var tick by remember { mutableLongStateOf(0L) }
40+ LaunchedEffect(epochMillis) {
41+ while (true) {
42+ delay(intervalMs)
43+ tick++
44+ }
45+ }
46+ return relativeTime(epochMillis).also { @Suppress("UNUSED_EXPRESSION") tick }
47+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+33−28
@@ -116,34 +116,7 @@ fun FileViewScreen(
116116 color = MaterialTheme.colorScheme.onSurfaceVariant,
117117 )
118118 } else {
119− val lines = remember(text) { text.split("\n") }
120− val gutterWidth = remember(lines.size) {
121− (lines.size.toString().length.coerceAtLeast(3) * 10).dp
122− }
123− val hScrollState = rememberScrollState()
124− LazyColumn(
125− modifier = Modifier
126− .fillMaxSize()
127− .padding(8.dp),
128− ) {
129− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
130− Row(modifier = Modifier.horizontalScroll(hScrollState)) {
131− Text(
132− "${index + 1}",
133− modifier = Modifier.width(gutterWidth),
134− style = MaterialTheme.typography.bodySmall,
135− fontFamily = FontFamily.Monospace,
136− color = MaterialTheme.colorScheme.onSurfaceVariant,
137− )
138− Text(
139− line,
140− modifier = Modifier.padding(start = 8.dp),
141− style = MaterialTheme.typography.bodySmall,
142− fontFamily = FontFamily.Monospace,
143− )
144− }
145− }
146− }
119+ FileTextContent(text)
147120 }
148121 }
149122 }
@@ -151,3 +124,35 @@ fun FileViewScreen(
151124 }
152125 }
153126}
127+
128+@Composable
129+private fun FileTextContent(text: String) {
130+ val lines = remember(text) { text.split("\n") }
131+ val gutterWidth = remember(lines.size) {
132+ (lines.size.toString().length.coerceAtLeast(3) * 10).dp
133+ }
134+ val hScrollState = rememberScrollState()
135+ LazyColumn(
136+ modifier = Modifier
137+ .fillMaxSize()
138+ .padding(8.dp),
139+ ) {
140+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
141+ Row(modifier = Modifier.horizontalScroll(hScrollState)) {
142+ Text(
143+ "${index + 1}",
144+ modifier = Modifier.width(gutterWidth),
145+ style = MaterialTheme.typography.bodySmall,
146+ fontFamily = FontFamily.Monospace,
147+ color = MaterialTheme.colorScheme.onSurfaceVariant,
148+ )
149+ Text(
150+ line,
151+ modifier = Modifier.padding(start = 8.dp),
152+ style = MaterialTheme.typography.bodySmall,
153+ fontFamily = FontFamily.Monospace,
154+ )
155+ }
156+ }
157+ }
158+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+2−2
@@ -71,7 +71,7 @@ import soy.iko.opencode.data.model.Session
7171import soy.iko.opencode.di.AppContainer
7272import soy.iko.opencode.R
7373import soy.iko.opencode.ui.components.ConnectionBanner
74−import soy.iko.opencode.ui.components.relativeTime
74+import soy.iko.opencode.ui.components.rememberRelativeTime
7575import soy.iko.opencode.ui.vmFactory
7676
7777@OptIn(ExperimentalMaterial3Api::class)
@@ -361,7 +361,7 @@ private fun SessionCard(
361361 overflow = TextOverflow.Ellipsis,
362362 modifier = Modifier.weight(1f),
363363 )
364− val time = relativeTime(session.time?.updated ?: session.time?.created)
364+ val time = rememberRelativeTime(session.time?.updated ?: session.time?.created)
365365 if (time.isNotEmpty()) {
366366 Text(
367367 time,
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+27−9
@@ -88,6 +88,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
8888
8989 private var previewJob: Job? = null
9090
91+ /** Per-session in-flight preview loads, so rapid SSE events for the same session
92+ * coalesce into a single request instead of firing one per event. */
93+ private val livePreviewJobs = mutableMapOf<String, Job>()
94+
9195 private val activeProfileId: String?
9296 get() = container.activeConnection.value?.profile?.id
9397
@@ -115,6 +119,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
115119 is SessionDeleted -> {
116120 val id = event.properties.info?.id ?: event.properties.sessionID
117121 if (id != null) {
122+ livePreviewJobs.remove(id)?.cancel()
123+ container.draftStore.remove(id)
118124 _state.update { s ->
119125 s.copy(
120126 sessions = s.sessions.filterNot { it.id == id },
@@ -167,17 +173,26 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
167173 }
168174 }
169175
170− /** Reload the preview for a single session (used by the live SSE handler). */
176+ /**
177+ * Reload the preview for a single session (used by the live SSE handler).
178+ * Cancels any prior in-flight load for the same session so a burst of
179+ * [SessionUpdated] events coalesces into a single request.
180+ */
171181 private fun loadPreview(sessionId: String) {
172182 val conn = container.activeConnection.value ?: return
173183 val api = conn.api
174− viewModelScope.launch {
175− val preview = runCatching {
176− api.listMessages(sessionId).lastOrNull()?.let { msg ->
177− msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
178− }
179− }.getOrNull()?.takeIf { it.isNotBlank() }?.take(200) ?: return@launch
180− _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
184+ livePreviewJobs.remove(sessionId)?.cancel()
185+ livePreviewJobs[sessionId] = viewModelScope.launch {
186+ try {
187+ val preview = runCatching {
188+ api.listMessages(sessionId).lastOrNull()?.let { msg ->
189+ msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
190+ }
191+ }.getOrNull()?.takeIf { it.isNotBlank() }?.take(200) ?: return@launch
192+ _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
193+ } finally {
194+ livePreviewJobs.remove(sessionId)
195+ }
181196 }
182197 }
183198
@@ -224,7 +239,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
224239 val conn = container.activeConnection.value ?: return
225240 viewModelScope.launch {
226241 runCatching { conn.repository.deleteSession(session.id) }
227− .onSuccess { refresh() }
242+ .onSuccess {
243+ container.draftStore.remove(session.id)
244+ refresh()
245+ }
228246 .onFailure {
229247 _state.value = _state.value.copy(error = null)
230248 _transientError.value = container.friendlyError(it)