git.iko.soy/opencode-android
on master help sign in
Add sendPrompt retry with idempotency key, catalog caching, DraftStore async I/O, SSE keepalive handling, MessageStore eviction cap, symmetric backoff jitter, server-switch state reset, and accessibility, ProGuard, and lifecycle hardening fixes
commita4d70405ae3276f4a919c0ab70ff87ffcbd7b7d6 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 06:24 (3 months ago) parent8115408
18 files changed+256−48
app/build.gradle.kts−1 app/proguard-rules.pro+18−1 app/src/main/java/soy/iko/opencode/OpencodeApp.kt+6 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+9−2 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+11−3 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+62−9 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+43−22 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+2−2 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+15 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+4 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+1−1 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+1−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+8−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+9−2 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+26−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+1−1 app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+18 app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt+22
app/build.gradle.kts−1
@@ -122,7 +122,6 @@ dependencies {
122122 implementation(libs.ktor.client.content.negotiation)
123123 implementation(libs.ktor.serialization.kotlinx.json)
124124 implementation(libs.ktor.client.auth)
125− implementation(libs.ktor.client.logging)
126125
127126 implementation(libs.kotlinx.serialization.json)
128127 implementation(libs.kotlinx.coroutines.android)
app/proguard-rules.pro+18−1
@@ -17,14 +17,31 @@
1717-keep @kotlinx.serialization.Serializable class soy.iko.opencode.data.model.** { *; }
1818
1919# --- Ktor / OkHttp (HTTP client + SSE) ---
20+# Don't warn about missing optional dependencies; keep only the classes that are
21+# accessed via reflection (Ktor engine registration, OkHttp internals that R8
22+# would otherwise strip). Narrow keeps instead of `-keep class io.ktor.** { *; }`
23+# so R8 can still eliminate dead code and shrink the APK.
2024-dontwarn io.ktor.**
2125-dontwarn okhttp3.**
2226-dontwarn okio.**
2327-dontwarn org.slf4j.**
24−-keep class io.ktor.** { *; }
28+-dontwarn org.jetbrains.annotations.**
29+# Ktor loads engines and plugins via ServiceLoader — keep the service files.
30+-keep class io.ktor.client.engine.** { *; }
31+-keep class io.ktor.client.plugins.** { *; }
32+-keep class io.ktor.serialization.** { *; }
33+-keep class io.ktor.http.** { *; }
34+# OkHttp uses reflection for platform detection and internal classes.
35+-keep class okhttp3.internal.platform.** { *; }
2536-keep class okhttp3.** { *; }
2637-keep class okio.** { *; }
2738
39+# --- Coil (image loading) ---
40+# Coil uses reflection for loadable decoders and the Compose integration.
41+-keep class coil.** { *; }
42+-keep class coil3.** { *; }
43+-dontwarn coil.**
44+
2845# --- androidx.security.crypto pulls in Tink, which references compile-only
2946# com.google.errorprone annotations. They aren't on the runtime classpath.
3047-dontwarn com.google.errorprone.annotations.**
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+6
@@ -12,6 +12,12 @@ class OpencodeApp : Application() {
1212 super.onCreate()
1313 CrashLogger.get(this).install()
1414 container = AppContainer(this)
15+ // onTerminate() is only called in emulated process environments, not on real
16+ // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
17+ // scope cancellation, connection close) runs when the process is actually killed.
18+ Runtime.getRuntime().addShutdownHook(Thread {
19+ container.shutdown()
20+ })
1521 }
1622
1723 override fun onTerminate() {
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+9−2
@@ -88,7 +88,14 @@ class EventStreamClient(
8888 }
8989 if (result == null) throw IOException("SSE idle timeout, reconnecting")
9090 val sse = result.getOrNull() ?: break
91− val data = sse.data ?: continue
91+ val data = sse.data
92+ if (data == null) {
93+ // A comment or keep-alive event (no data field) resets the idle watchdog
94+ // — servers send `: ping` comments to keep the connection alive through
95+ // proxies. Without this, a server that only sends comments would still
96+ // hit the idle timeout and reconnect unnecessarily.
97+ continue
98+ }
9299 val event = try {
93100 OpencodeJson.decodeFromString(BusEvent.serializer(), data)
94101 } catch (e: kotlinx.serialization.SerializationException) {
@@ -163,7 +170,7 @@ class EventStreamClient(
163170 if (!isActive) break
164171 // Wait for the backoff, but allow a reconnect signal to cut it short.
165172 var signaled = false
166− val jitter = (backoffMs * 0.2 * Random.nextDouble()).toLong()
173+ val jitter = ((backoffMs * 0.2) * (Random.nextDouble() * 2 - 1)).toLong()
167174 select {
168175 reconnectSignal.onReceive { signaled = true }
169176 onTimeout(backoffMs + jitter) { /* normal backoff elapsed */ }
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+11−3
@@ -24,7 +24,7 @@ object NetworkConfig {
2424 const val retryMaxAttempts = 3
2525 /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
2626 const val retryInitialDelayMs = 500L
27− /** Jitter factor for retry backoff (0.2 = ±20% random jitter added to each delay). */
27+ /** Jitter factor for retry backoff (0.2 = ±20% symmetric random jitter on each delay). */
2828 const val retryJitterFactor = 0.2
2929
3030 // --- SSE stream (EventStreamClient) ---
@@ -37,8 +37,11 @@ object NetworkConfig {
3737 const val sseIdleTimeoutMs = 90_000L
3838 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
3939 const val sseEventBufferCapacity = 64
40− /** Max time to wait for the first SSE event before treating the connection as stale. */
41− const val sseHandshakeTimeoutMs = 30_000L
40+
41+ // --- Catalog cache (OpencodeApiClient) ---
42+
43+ /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
44+ const val catalogCacheTtlMs = 60_000L
4245
4346 // --- Session list (SessionListViewModel) ---
4447
@@ -49,6 +52,11 @@ object NetworkConfig {
4952 /** Max characters of a session's last message to keep as a list preview. */
5053 const val previewTextMaxLength = 200
5154
55+ // --- In-memory message store (SessionRepository.MessageStore) ---
56+
57+ /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
58+ const val maxInMemoryMessages = 500
59+
5260 // --- File browser (FileBrowserViewModel) ---
5361
5462 /** Debounce delay before firing a file-search request after the user stops typing. */
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+62−9
@@ -21,6 +21,7 @@ import io.ktor.client.call.body
2121import io.ktor.client.plugins.ClientRequestException
2222import io.ktor.client.request.delete
2323import io.ktor.client.request.get
24+import io.ktor.client.request.header
2425import io.ktor.client.request.parameter
2526import io.ktor.client.request.patch
2627import io.ktor.client.request.post
@@ -28,6 +29,8 @@ import io.ktor.client.request.setBody
2829import io.ktor.http.ContentType
2930import io.ktor.http.contentType
3031import kotlinx.coroutines.delay
32+import kotlinx.coroutines.sync.Mutex
33+import kotlinx.coroutines.sync.withLock
3134import kotlin.random.Random
3235import java.net.URLEncoder
3336
@@ -37,6 +40,16 @@ import java.net.URLEncoder
3740 */
3841class OpencodeApiClient(private val client: HttpClient) {
3942
43+ // --- Catalog cache (providers/agents/commands) ---
44+ // These change rarely, but the ChatViewModel re-fetches them on every creation.
45+ // A short-lived in-memory cache avoids redundant network calls when navigating
46+ // between sessions on the same server.
47+ private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
48+ private val cacheMutex = Mutex()
49+ private var cachedProviders: CachedEntry<ProvidersResponse>? = null
50+ private var cachedAgents: CachedEntry<List<Agent>>? = null
51+ private var cachedCommands: CachedEntry<List<Command>>? = null
52+
4053 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
4154 suspend fun ping() {
4255 client.get("global/health")
@@ -71,9 +84,14 @@ class OpencodeApiClient(private val client: HttpClient) {
7184 text: String,
7285 model: ModelRef? = null,
7386 agent: String? = null,
74− ): MessageWithParts =
87+ ): MessageWithParts = withRetry {
88+ // Send an idempotency key so the server can safely deduplicate if a transient
89+ // network failure causes this request to be retried. The key is unique per call
90+ // so a genuine re-send (e.g. retry-failed) still creates a new message.
91+ val idempotencyKey = java.util.UUID.randomUUID().toString()
7592 client.post("session/${encode(sessionId)}/message") {
7693 contentType(ContentType.Application.Json)
94+ header("Idempotency-Key", idempotencyKey)
7795 setBody(
7896 PromptRequest(
7997 parts = listOf(PromptPart(text = text)),
@@ -82,6 +100,7 @@ class OpencodeApiClient(private val client: HttpClient) {
82100 ),
83101 )
84102 }.body()
103+ }
85104
86105 suspend fun abort(sessionId: String) {
87106 client.post("session/${encode(sessionId)}/abort")
@@ -99,16 +118,47 @@ class OpencodeApiClient(private val client: HttpClient) {
99118 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
100119 }.body()
101120
102− suspend fun providers(): ProvidersResponse = withRetry {
103− client.get("config/providers").body()
121+ suspend fun providers(): ProvidersResponse = cacheMutex.withLock {
122+ val now = System.currentTimeMillis()
123+ val cached = cachedProviders
124+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
125+ cached.value
126+ } else {
127+ val value = withRetry { client.get("config/providers").body<ProvidersResponse>() }
128+ cachedProviders = CachedEntry(value, now)
129+ value
130+ }
131+ }
132+
133+ suspend fun agents(): List<Agent> = cacheMutex.withLock {
134+ val now = System.currentTimeMillis()
135+ val cached = cachedAgents
136+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
137+ cached.value
138+ } else {
139+ val value = withRetry { client.get("agent").body<List<Agent>>() }
140+ cachedAgents = CachedEntry(value, now)
141+ value
142+ }
104143 }
105144
106− suspend fun agents(): List<Agent> = withRetry {
107− client.get("agent").body()
145+ suspend fun commands(): List<Command> = cacheMutex.withLock {
146+ val now = System.currentTimeMillis()
147+ val cached = cachedCommands
148+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
149+ cached.value
150+ } else {
151+ val value = withRetry { client.get("command").body<List<Command>>() }
152+ cachedCommands = CachedEntry(value, now)
153+ value
154+ }
108155 }
109156
110− suspend fun commands(): List<Command> = withRetry {
111− client.get("command").body()
157+ /** Invalidate the catalog cache (e.g. on server switch). */
158+ fun invalidateCache() {
159+ cachedProviders = null
160+ cachedAgents = null
161+ cachedCommands = null
112162 }
113163
114164 /** Respond to a permission request so a paused tool run can proceed. */
@@ -176,8 +226,11 @@ internal suspend fun <T> withRetryInternal(
176226 }
177227 if (attempt < maxAttempts) {
178228 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
179− val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
180− delay(baseDelay + jitter)
229+ // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
230+ // shorter, sometimes longer — spreads concurrent client retries and
231+ // prevents thundering-herd reconnection storms.
232+ val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
233+ delay((baseDelay + jitter).coerceAtLeast(0))
181234 }
182235 }
183236 }
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+43−22
@@ -1,52 +1,73 @@
11package soy.iko.opencode.data.repo
22
33import android.content.Context
4−import java.util.concurrent.CountDownLatch
5−import java.util.concurrent.TimeUnit
64import kotlinx.coroutines.CoroutineScope
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
79import kotlinx.coroutines.launch
10+import kotlinx.coroutines.withContext
811
912/**
1013 * Persists the in-progress message draft for each session so it survives
1114 * back-navigation and process death. Backed by SharedPreferences because the
1215 * UI needs the initial value synchronously and drafts are low-stakes data.
1316 *
14− * The prefs file is loaded on a background thread at construction time so the
15− * first [get] call on the main thread doesn't trigger a synchronous disk read.
17+ * The prefs file is loaded on a background thread at construction time. Until
18+ * the load completes, [drafts] reports an empty map and [get] returns "" rather
19+ * than blocking the caller (previously a [CountDownLatch] could stall the main
20+ * thread for up to 2 seconds on a slow disk).
1621 */
1722class DraftStore(context: Context, scope: CoroutineScope) {
1823
1924 private val appContext = context.applicationContext
20− private val prefsReady = CountDownLatch(1)
2125 private val prefs by lazy {
2226 appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
2327 }
2428
29+ private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
30+ /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
31+ val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
32+
33+ private val _ready = MutableStateFlow(false)
34+ /** True once the background prefs load has completed. */
35+ val ready: StateFlow<Boolean> = _ready.asStateFlow()
36+
2537 init {
2638 // Eagerly load the prefs file on a background thread so the first main-thread
27− // get() doesn't block on disk I/O. The latch ensures get() waits for this
28− // background load instead of triggering its own lazy load on the main thread.
29− scope.launch {
30− runCatching { prefs.all }
31− prefsReady.countDown()
39+ // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
40+ // so callers can observe readiness without blocking.
41+ scope.launch(Dispatchers.IO) {
42+ val snapshot = runCatching {
43+ prefs.all.mapValues { it.value as? String ?: "" }
44+ }.getOrDefault(emptyMap())
45+ _drafts.value = snapshot
46+ _ready.value = true
3247 }
3348 }
3449
35− fun get(sessionId: String): String {
36− prefsReady.await(2, TimeUnit.SECONDS)
37− return prefs.getString(sessionId, "").orEmpty()
38− }
50+ /** Returns the draft for [sessionId], or "" if not found or still loading. */
51+ fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
3952
40− fun set(sessionId: String, text: String) {
41− prefsReady.await(2, TimeUnit.SECONDS)
42− prefs.edit().apply {
43− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
44− }.apply()
53+ suspend fun set(sessionId: String, text: String) {
54+ val current = _drafts.value.toMutableMap()
55+ if (text.isBlank()) current.remove(sessionId) else current[sessionId] = text
56+ _drafts.value = current
57+ withContext(Dispatchers.IO) {
58+ prefs.edit().apply {
59+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
60+ }.apply()
61+ }
4562 }
4663
4764 /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
48− fun remove(sessionId: String) {
49− prefsReady.await(2, TimeUnit.SECONDS)
50− prefs.edit().remove(sessionId).apply()
65+ suspend fun remove(sessionId: String) {
66+ val current = _drafts.value.toMutableMap()
67+ current.remove(sessionId)
68+ _drafts.value = current
69+ withContext(Dispatchers.IO) {
70+ prefs.edit().remove(sessionId).apply()
71+ }
5172 }
5273}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+2−2
@@ -113,7 +113,7 @@ class ProfileStore(context: Context) {
113113 } else {
114114 remove(passwordKey(profile.id))
115115 }
116− }.commit()
116+ }.apply()
117117 }
118118 }
119119
@@ -129,7 +129,7 @@ class ProfileStore(context: Context) {
129129 )
130130 }
131131 withContext(Dispatchers.IO) {
132− securePrefs.edit().remove(passwordKey(id)).commit()
132+ securePrefs.edit().remove(passwordKey(id)).apply()
133133 }
134134 }
135135
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+15
@@ -12,6 +12,7 @@ import soy.iko.opencode.data.model.SessionError
1212import soy.iko.opencode.data.model.SessionIdle
1313import soy.iko.opencode.data.model.UnknownMessage
1414import soy.iko.opencode.data.network.EventStreamClient
15+import soy.iko.opencode.data.network.NetworkConfig
1516import soy.iko.opencode.data.network.OpencodeApiClient
1617import android.util.Log
1718import kotlinx.coroutines.flow.Flow
@@ -119,6 +120,9 @@ internal class MessageStore {
119120 // messageId -> (info + parts), insertion-ordered.
120121 private val messages = LinkedHashMap<String, MessageWithParts>()
121122
123+ /** Maximum number of messages to keep in memory; oldest are evicted when exceeded. */
124+ internal val maxMessages = NetworkConfig.maxInMemoryMessages
125+
122126 fun snapshot(): List<MessageWithParts> = messages.values.toList()
123127
124128 fun seed(initial: List<MessageWithParts>, prune: Boolean = false) {
@@ -152,6 +156,7 @@ internal class MessageStore {
152156 messages[m.info.id] = MessageWithParts(info = info, parts = ordered)
153157 }
154158 }
159+ evictOldMessages()
155160 }
156161
157162 /** Returns true if the state changed (and a new snapshot should be published). */
@@ -164,6 +169,7 @@ internal class MessageStore {
164169 } else {
165170 val existing = messages[info.id]
166171 messages[info.id] = existing?.copy(info = info) ?: MessageWithParts(info)
172+ if (existing == null) evictOldMessages()
167173 true
168174 }
169175 }
@@ -208,6 +214,7 @@ internal class MessageStore {
208214 info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
209215 parts = newParts,
210216 )
217+ evictOldMessages()
211218 return true
212219 }
213220
@@ -218,4 +225,12 @@ internal class MessageStore {
218225 messages[messageId] = current.copy(parts = newParts)
219226 return true
220227 }
228+
229+ /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded. */
230+ private fun evictOldMessages() {
231+ while (messages.size > maxMessages) {
232+ val oldestKey = messages.keys.iterator().next()
233+ messages.remove(oldestKey)
234+ }
235+ }
221236}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+4
@@ -178,6 +178,7 @@ class AppContainer(context: Context) {
178178 // Track sessions actively streaming so we know which idle events
179179 // represent a finished run worth notifying about.
180180 if (event is MessagePartUpdated || event is MessageUpdated) {
181+ if (activeRuns.size >= activeRunsLimit) activeRuns.clear()
181182 activeRuns.add(sid)
182183 }
183184 }
@@ -187,6 +188,9 @@ class AppContainer(context: Context) {
187188 /** Session ids currently streaming an assistant run (best-effort, in-process). */
188189 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
189190
191+ /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
192+ private val activeRunsLimit = 200
193+
190194 /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
191195 private val connectionMutex = Mutex()
192196
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+1−1
@@ -54,8 +54,8 @@ object SessionNotifications {
5454 val notifId = notifIdFor(sessionId)
5555 val openIntent = Intent(context, MainActivity::class.java).apply {
5656 action = Intent.ACTION_VIEW
57+ data = android.net.Uri.parse("opencode://session/$sessionId")
5758 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
58− putExtra(MainActivity.EXTRA_SESSION_ID, sessionId)
5959 }
6060 val pendingIntent = PendingIntent.getActivity(
6161 context, notifId, openIntent,
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+1−1
@@ -94,7 +94,7 @@ fun OpencodeApp(container: AppContainer) {
9494 SessionListScreen(
9595 container = container,
9696 onOpenSession = { id ->
97− container.consumePendingShare()?.let { container.draftStore.set(id, it) }
97+ container.consumePendingShare()?.let { scope.launch { container.draftStore.set(id, it) } }
9898 navController.navigate(Routes.chat(id))
9999 },
100100 onDisconnect = {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+8−2
@@ -72,6 +72,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
7272import androidx.compose.ui.platform.LocalView
7373import androidx.compose.ui.res.stringResource
7474import androidx.compose.ui.semantics.Role
75+import androidx.compose.ui.semantics.contentDescription
7576import androidx.compose.ui.semantics.role
7677import androidx.compose.ui.semantics.semantics
7778import androidx.compose.ui.text.input.ImeAction
@@ -301,12 +302,17 @@ fun ChatScreen(
301302 contentPadding = PaddingValues(16.dp),
302303 verticalArrangement = Arrangement.spacedBy(16.dp),
303304 ) {
304− items(messages, key = { it.info.id }) { message ->
305+ items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
305306 MessageBubble(message, isRunning = running, imageContext = imageContext)
306307 }
307308 if (running) {
308309 item(key = "__typing") {
309− Row(verticalAlignment = Alignment.CenterVertically) {
310+ Row(
311+ verticalAlignment = Alignment.CenterVertically,
312+ modifier = Modifier.semantics {
313+ contentDescription = shareContext.getString(R.string.working)
314+ },
315+ ) {
310316 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
311317 Text(stringResource(R.string.working), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
312318 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+9−2
@@ -120,14 +120,21 @@ class ChatViewModel(
120120
121121 fun updateDraft(text: String) {
122122 _draft.value = text
123− container.draftStore.set(sessionId, text)
123+ viewModelScope.launch { container.draftStore.set(sessionId, text) }
124124 }
125125
126126 init {
127− // Watch the bus for run completion / errors / permission asks for this session.
127+ // Reset per-connection state when the active server changes so stale spinners,
128+ // permission dialogs, errors, and agent selections from the old server don't
129+ // persist into the new one.
128130 viewModelScope.launch {
129131 container.activeConnection.collectLatest { conn ->
130132 if (conn == null) return@collectLatest
133+ _running.value = false
134+ _pendingPermission.value = null
135+ _error.value = null
136+ _failedDraft.value = null
137+ _selectedAgent.value = null
131138 conn.events.events.collect { event ->
132139 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
133140 if (SessionRepository.isError(event, sessionId)) {
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+26−1
@@ -22,6 +22,11 @@ import androidx.compose.material3.MaterialTheme
2222import androidx.compose.material3.Surface
2323import androidx.compose.material3.Text
2424import androidx.compose.runtime.Composable
25+import androidx.compose.runtime.LaunchedEffect
26+import androidx.compose.runtime.getValue
27+import androidx.compose.runtime.mutableStateOf
28+import androidx.compose.runtime.remember
29+import androidx.compose.runtime.setValue
2530import androidx.compose.ui.Alignment
2631import androidx.compose.ui.Modifier
2732import androidx.compose.ui.draw.clip
@@ -29,6 +34,7 @@ import androidx.compose.ui.platform.LocalContext
2934import androidx.compose.ui.text.TextStyle
3035import androidx.compose.ui.text.font.FontFamily
3136import androidx.compose.ui.unit.dp
37+import kotlinx.coroutines.delay
3238import com.mikepenz.markdown.compose.components.MarkdownComponentModel
3339import com.mikepenz.markdown.compose.components.markdownComponents
3440import com.mikepenz.markdown.m3.Markdown
@@ -42,6 +48,11 @@ import soy.iko.opencode.R
4248 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
4349 * Long-press copies the raw markdown to the system clipboard. Code blocks/fences get an
4450 * inline copy button.
51+ *
52+ * During streaming, the full markdown is re-parsed on every token (the library re-parses
53+ * whenever the content string changes). To avoid O(n²) work during long responses, the
54+ * rendered content is throttled — the latest [markdown] is committed to the renderer at
55+ * most once per frame (~16ms), so a burst of tokens coalesces into a single re-parse.
4556 */
4657@OptIn(ExperimentalFoundationApi::class)
4758@Composable
@@ -51,8 +62,22 @@ fun MarkdownText(
5162 style: TextStyle = MaterialTheme.typography.bodyLarge,
5263) {
5364 val context = LocalContext.current
65+ // Throttle the rendered content so a rapid burst of streaming tokens coalesces
66+ // into a single markdown re-parse per frame instead of one per token. The delay
67+ // is only applied when the content is growing incrementally (streaming) — a
68+ // content switch or initial render proceeds immediately.
69+ var renderedContent by remember { mutableStateOf(markdown) }
70+ LaunchedEffect(markdown) {
71+ if (renderedContent.isNotEmpty() &&
72+ markdown.startsWith(renderedContent) &&
73+ markdown != renderedContent
74+ ) {
75+ delay(16)
76+ }
77+ renderedContent = markdown
78+ }
5479 Markdown(
55− content = markdown,
80+ content = renderedContent,
5681 modifier = modifier.combinedClickable(
5782 onClick = {},
5883 onLongClick = { copyToClipboard(context, markdown) },
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+1−1
@@ -96,7 +96,7 @@ fun SessionListScreen(
9696 val unread by vm.unread.collectAsStateWithLifecycle()
9797 val haptics = LocalHapticFeedback.current
9898 val snackbar = remember { SnackbarHostState() }
99− var showServerMenu by remember { mutableStateOf(false) }
99+ var showServerMenu by rememberSaveable { mutableStateOf(false) }
100100 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
101101 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
102102 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+18
@@ -158,4 +158,22 @@ class WithRetryTest {
158158 // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
159159 assertEquals(listOf(50L, 100L, 200L), delays)
160160 }
161+
162+ @Test
163+ fun symmetricJitterStaysWithinBounds() = runTest {
164+ // With jitterFactor = 0.2, the delay should be baseDelay ± 20%.
165+ // baseDelay = 100, so delay ∈ [80, 120]. Run many times to verify
166+ // the symmetric jitter never exceeds the bounds.
167+ repeat(100) {
168+ val start = testScheduler.currentTime
169+ runCatching {
170+ withRetryInternal(maxAttempts = 2, initialDelayMs = 100L, jitterFactor = 0.2) {
171+ throw IOException("always")
172+ }
173+ }
174+ val elapsed = testScheduler.currentTime - start
175+ assertTrue("elapsed $elapsed should be >= 80", elapsed >= 80)
176+ assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
177+ }
178+ }
161179}
app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt+22
@@ -295,4 +295,26 @@ class MessageStoreTest {
295295 assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
296296 assertEquals("final", (a1.parts[0] as TextPart).text)
297297 }
298+
299+ // --- eviction ---
300+
301+ @Test
302+ fun evictionCapsMessageCountKeepingNewest() {
303+ val store = MessageStore()
304+ // Seed exactly at the cap.
305+ val messages = (1..store.maxMessages).map { i ->
306+ MessageWithParts(UserMessage("m$i", session))
307+ }
308+ store.seed(messages)
309+ assertEquals(store.maxMessages, store.snapshot().size)
310+
311+ // Adding one more via reduce should evict the oldest.
312+ store.reduce(session, msgUpdated("m${store.maxMessages + 1}"))
313+ assertEquals(store.maxMessages, store.snapshot().size)
314+ // The oldest (m1) should be gone; the newest should be present.
315+ val ids = store.snapshot().map { it.info.id }
316+ assertFalse("m1 should have been evicted", ids.contains("m1"))
317+ assertTrue("m${store.maxMessages} should still be present", ids.contains("m${store.maxMessages}"))
318+ assertTrue("newest message should be present", ids.contains("m${store.maxMessages + 1}"))
319+ }
298320}