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
80ddf73iko
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+6−2
@@ -18,6 +18,7 @@ import soy.iko.opencode.data.model.Session
18import soy.iko.opencode.data.model.UpdateSessionRequest
18import soy.iko.opencode.data.model.UpdateSessionRequest
19import io.ktor.client.HttpClient
19import io.ktor.client.HttpClient
20import io.ktor.client.call.body
20import io.ktor.client.call.body
21+import io.ktor.client.plugins.ClientRequestException
21import io.ktor.client.request.delete
22import io.ktor.client.request.delete
22import io.ktor.client.request.get
23import io.ktor.client.request.get
23import io.ktor.client.request.parameter
24import io.ktor.client.request.parameter
@@ -142,8 +143,10 @@ class OpencodeApiClient(private val client: HttpClient) {
142
143
143/**
144/**
144 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
145 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
145− * Cancellation exceptions are re-thrown immediately. Non-2xx HTTP responses throw
146+ * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
146− * (because [HttpClientFactory] sets `expectSuccess = true`) and are retried.
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.
147 */
150 */
148private suspend fun <T> withRetry(
151private suspend fun <T> withRetry(
149 maxAttempts: Int = 3,
152 maxAttempts: Int = 3,
@@ -158,6 +161,7 @@ private suspend fun <T> withRetry(
158 throw c
161 throw c
159 } catch (t: Throwable) {
162 } catch (t: Throwable) {
160 lastError = t
163 lastError = t
164+ if (t is ClientRequestException) throw t
161 if (attempt < maxAttempts) delay(initialDelayMs * (1 shl (attempt - 1)))
165 if (attempt < maxAttempts) delay(initialDelayMs * (1 shl (attempt - 1)))
162 }
166 }
163 }
167 }
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+5
@@ -19,4 +19,9 @@ class DraftStore(context: Context) {
19 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
19 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
20 }.apply()
20 }.apply()
21 }
21 }
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+ }
22}
27}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+2−2
@@ -19,7 +19,7 @@ import soy.iko.opencode.data.model.MessageWithParts
19import soy.iko.opencode.data.model.Tokens
19import soy.iko.opencode.data.model.Tokens
20import soy.iko.opencode.data.model.UserMessage
20import soy.iko.opencode.data.model.UserMessage
21import soy.iko.opencode.ui.components.ImageLoadContext
21import soy.iko.opencode.ui.components.ImageLoadContext
22−import soy.iko.opencode.ui.components.relativeTime
22+import soy.iko.opencode.ui.components.rememberRelativeTime
23
23
24/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
24/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
25@Composable
25@Composable
@@ -92,7 +92,7 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageC
92
92
93@Composable
93@Composable
94private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
94private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
95− val t = relativeTime(info.time?.created)
95+ val t = rememberRelativeTime(info.time?.created)
96 if (t.isNotEmpty()) {
96 if (t.isNotEmpty()) {
97 Text(
97 Text(
98 t,
98 t,
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+25
@@ -1,5 +1,12 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
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
3import java.util.concurrent.TimeUnit
10import java.util.concurrent.TimeUnit
4
11
5/** Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d"). */
12/** Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d"). */
@@ -20,3 +27,21 @@ fun relativeTime(epochMillis: Long?): String {
20 if (months < 12) return "${months}mo"
27 if (months < 12) return "${months}mo"
21 return "${days / 365}y"
28 return "${days / 365}y"
22}
29}
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(
116 color = MaterialTheme.colorScheme.onSurfaceVariant,
116 color = MaterialTheme.colorScheme.onSurfaceVariant,
117 )
117 )
118 } else {
118 } else {
119− val lines = remember(text) { text.split("\n") }
119+ FileTextContent(text)
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− }
147 }
120 }
148 }
121 }
149 }
122 }
@@ -151,3 +124,35 @@ fun FileViewScreen(
151 }
124 }
152 }
125 }
153}
126}
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
71import soy.iko.opencode.di.AppContainer
71import soy.iko.opencode.di.AppContainer
72import soy.iko.opencode.R
72import soy.iko.opencode.R
73import soy.iko.opencode.ui.components.ConnectionBanner
73import soy.iko.opencode.ui.components.ConnectionBanner
74−import soy.iko.opencode.ui.components.relativeTime
74+import soy.iko.opencode.ui.components.rememberRelativeTime
75import soy.iko.opencode.ui.vmFactory
75import soy.iko.opencode.ui.vmFactory
76
76
77@OptIn(ExperimentalMaterial3Api::class)
77@OptIn(ExperimentalMaterial3Api::class)
@@ -361,7 +361,7 @@ private fun SessionCard(
361 overflow = TextOverflow.Ellipsis,
361 overflow = TextOverflow.Ellipsis,
362 modifier = Modifier.weight(1f),
362 modifier = Modifier.weight(1f),
363 )
363 )
364− val time = relativeTime(session.time?.updated ?: session.time?.created)
364+ val time = rememberRelativeTime(session.time?.updated ?: session.time?.created)
365 if (time.isNotEmpty()) {
365 if (time.isNotEmpty()) {
366 Text(
366 Text(
367 time,
367 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() {
88
88
89 private var previewJob: Job? = null
89 private var previewJob: Job? = null
90
90
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+
91 private val activeProfileId: String?
95 private val activeProfileId: String?
92 get() = container.activeConnection.value?.profile?.id
96 get() = container.activeConnection.value?.profile?.id
93
97
@@ -115,6 +119,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
115 is SessionDeleted -> {
119 is SessionDeleted -> {
116 val id = event.properties.info?.id ?: event.properties.sessionID
120 val id = event.properties.info?.id ?: event.properties.sessionID
117 if (id != null) {
121 if (id != null) {
122+ livePreviewJobs.remove(id)?.cancel()
123+ container.draftStore.remove(id)
118 _state.update { s ->
124 _state.update { s ->
119 s.copy(
125 s.copy(
120 sessions = s.sessions.filterNot { it.id == id },
126 sessions = s.sessions.filterNot { it.id == id },
@@ -167,17 +173,26 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
167 }
173 }
168 }
174 }
169
175
176+ /**
170− /** Reload the preview for a single session (used by the live SSE handler). */
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+ */
171 private fun loadPreview(sessionId: String) {
181 private fun loadPreview(sessionId: String) {
172 val conn = container.activeConnection.value ?: return
182 val conn = container.activeConnection.value ?: return
173 val api = conn.api
183 val api = conn.api
184+ livePreviewJobs.remove(sessionId)?.cancel()
174− viewModelScope.launch {
185+ livePreviewJobs[sessionId] = viewModelScope.launch {
186+ try {
175− val preview = runCatching {
187+ val preview = runCatching {
176− api.listMessages(sessionId).lastOrNull()?.let { msg ->
188+ api.listMessages(sessionId).lastOrNull()?.let { msg ->
177− msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
189+ msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
178− }
190+ }
179− }.getOrNull()?.takeIf { it.isNotBlank() }?.take(200) ?: return@launch
191+ }.getOrNull()?.takeIf { it.isNotBlank() }?.take(200) ?: return@launch
180− _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
192+ _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
193+ } finally {
194+ livePreviewJobs.remove(sessionId)
195+ }
181 }
196 }
182 }
197 }
183
198
@@ -224,7 +239,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
224 val conn = container.activeConnection.value ?: return
239 val conn = container.activeConnection.value ?: return
225 viewModelScope.launch {
240 viewModelScope.launch {
226 runCatching { conn.repository.deleteSession(session.id) }
241 runCatching { conn.repository.deleteSession(session.id) }
227− .onSuccess { refresh() }
242+ .onSuccess {
243+ container.draftStore.remove(session.id)
244+ refresh()
245+ }
228 .onFailure {
246 .onFailure {
229 _state.value = _state.value.copy(error = null)
247 _state.value = _state.value.copy(error = null)
230 _transientError.value = container.friendlyError(it)
248 _transientError.value = container.friendlyError(it)