git.iko.soy/opencode-android
on master help sign in
Reduce streaming-path allocations via conflated snapshot drains, memoized cost formatting, a session-filter fast-path, and a Coil disk cache
commitf50f967fa7b184b68980378d889ea5b21717d07b authoriko <mail@iko.soy> dateWed 1 Jul 2026, 06:59 (3 months ago) parent734c312
6 files changed+100−43
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+23−1 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+24−17 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+16−7 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+16−11 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+7−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+14−6
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+23−1
@@ -1,11 +1,15 @@
11package soy.iko.opencode
22
33import android.app.Application
4+import coil.ImageLoader
5+import coil.ImageLoaderFactory
6+import coil.disk.DiskCache
7+import coil.memory.MemoryCache
48import soy.iko.opencode.data.repo.CrashLogger
59import soy.iko.opencode.di.AppContainer
610import java.util.concurrent.atomic.AtomicBoolean
711
8−class OpencodeApp : Application() {
12+class OpencodeApp : Application(), ImageLoaderFactory {
913 lateinit var container: AppContainer
1014 private set
1115
@@ -23,6 +27,24 @@ class OpencodeApp : Application() {
2327 })
2428 }
2529
30+ // Process-wide Coil singleton. The defaults are sane; the explicit factory adds a
31+ // disk cache so scrolling back to an image-bearing message doesn't re-fetch it over
32+ // the (auth-protected) network every time. Data-URI (base64) images skip the disk
33+ // cache automatically; only resolved HTTP/S URLs are cached, keyed by URL.
34+ override fun newImageLoader(): ImageLoader = ImageLoader.Builder(this)
35+ .memoryCache {
36+ MemoryCache.Builder(this)
37+ .maxSizePercent(0.25)
38+ .build()
39+ }
40+ .diskCache {
41+ DiskCache.Builder()
42+ .directory(cacheDir.resolve("image_cache"))
43+ .maxSizeBytes(50L * 1024 * 1024)
44+ .build()
45+ }
46+ .build()
47+
2648 override fun onTerminate() {
2749 super.onTerminate()
2850 shutdown()
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+24−17
@@ -17,8 +17,10 @@ import soy.iko.opencode.data.network.OpencodeApiClient
1717import soy.iko.opencode.util.runCatchingCancellable
1818import soy.iko.opencode.util.safeExceptionSummary
1919import android.util.Log
20+import kotlinx.coroutines.channels.Channel
2021import kotlinx.coroutines.flow.Flow
2122import kotlinx.coroutines.flow.channelFlow
23+import kotlinx.coroutines.flow.consumeAsFlow
2224import kotlinx.coroutines.flow.conflate
2325import kotlinx.coroutines.launch
2426import kotlinx.coroutines.sync.Mutex
@@ -58,25 +60,23 @@ open class SessionRepository(
5860 open fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
5961 val store = MessageStore()
6062 val lock = Mutex()
63+ // Conflated dirty signal: a burst of reduce()/seed() calls coalesces into a
64+ // single snapshot+send per demand cycle. Previously every changed event called
65+ // snapshot() — allocating an O(N) copy of the whole message list — and ~97% of
66+ // those copies were immediately discarded by the outer .conflate() during fast
67+ // streaming (hundreds of tokens/sec). Marking dirty and snapshotting in one
68+ // drain loop means N events in a burst allocate ~1 snapshot, not N.
69+ val dirty = Channel<Unit>(Channel.CONFLATED)
6170
62− // Collect the snapshot under the lock, then publish outside it so a slow
63− // downstream collector can't stall event processing.
64− suspend fun publish() {
65− val snapshot = lock.withLock { store.snapshot() }
66− send(snapshot)
67− }
71+ // Mark the store dirty; the drain loop below snapshots and publishes. CONFLATED
72+ // collapses repeated marks into a single pending signal, so a tight burst of
73+ // events doesn't even allocate N marks.
74+ fun publish() { dirty.trySend(Unit) }
6875
6976 // Subscribe to events first so we don't miss early deltas during the initial load.
70− val job = launch {
77+ launch {
7178 eventStream.events.collect { event ->
72− // Reduce and snapshot in a single lock acquisition: during fast streaming
73− // (hundreds of tokens/sec) this halves mutex contention vs. acquiring the
74− // lock once for reduce and again for snapshot. send() is called outside the
75− // lock so a slow downstream collector can't stall event processing.
76− val snapshot = lock.withLock {
77− if (store.reduce(sessionId, event)) store.snapshot() else null
78− }
79− if (snapshot != null) send(snapshot)
79+ if (lock.withLock { store.reduce(sessionId, event) }) publish()
8080 }
8181 }
8282
@@ -128,8 +128,15 @@ open class SessionRepository(
128128 lock.withLock { store.seed(initial) }
129129 publish()
130130
131− // Keep the flow alive until the collector cancels; the launched job is torn down with it.
132− job.join()
131+ // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
132+ // taken under the lock so a reader never sees a half-reduced state; send() runs
133+ // outside the lock so a slow downstream collector can't stall the drain. This
134+ // loop also keeps the flow alive until the collector cancels; the launched jobs
135+ // above are children of this scope and are torn down with it.
136+ dirty.consumeAsFlow().collect {
137+ val snapshot = lock.withLock { store.snapshot() }
138+ send(snapshot)
139+ }
133140 }.conflate()
134141
135142 companion object {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+16−7
@@ -285,13 +285,15 @@ fun ChatScreen(
285285
286286 LaunchedEffect(Unit) {
287287 snapshotFlow {
288− Triple(
289− messages.size,
290− messages.lastOrNull()?.parts?.sumOf { (it as? TextPart)?.text?.length ?: 0 } ?: 0,
291− isPinnedToBottom,
292− )
293− }.collect { (_, _, pinned) ->
294− if (messages.isNotEmpty() && pinned) {
288+ // Track list size + the streaming text length of the last message so we
289+ // auto-scroll as tokens arrive, plus the pinned flag. A small data class
290+ // with primitive fields avoids the per-frame boxing that Triple<Int,Int,Boolean>
291+ // pays (snapshotFlow re-evaluates this lambda every snapshot), and reading
292+ // the last TextPart's length is O(1) vs. the prior sumOf over every part.
293+ val lastText = messages.lastOrNull()?.parts?.lastOrNull() as? TextPart
294+ AutoScrollSignal(messages.size, lastText?.text?.length ?: 0, isPinnedToBottom)
295+ }.collect { signal ->
296+ if (signal.size > 0 && signal.pinned) {
295297 listState.scrollToItem(messages.lastIndex)
296298 }
297299 }
@@ -532,3 +534,10 @@ private fun EmptyConversation(modifier: Modifier = Modifier) {
532534 )
533535 }
534536}
537+
538+/**
539+ * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
540+ * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
541+ * equality is structural so `snapshotFlow` emits only when something relevant changes.
542+ */
543+private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+16−11
@@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
1010import androidx.compose.material3.MaterialTheme
1111import androidx.compose.material3.Text
1212import androidx.compose.runtime.Composable
13+import androidx.compose.runtime.remember
1314import androidx.compose.ui.Alignment
1415import androidx.compose.ui.Modifier
1516import androidx.compose.ui.draw.clip
@@ -75,19 +76,23 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageC
7576 if (info is AssistantMessage) {
7677 val cost = info.cost
7778 val tokens = info.tokens
78− if (info.isComplete && (cost != null || tokens != null)) {
79− val tokenFormat = stringResource(R.string.tokens_in_out)
80− val parts = buildList {
79+ val tokenFormat = stringResource(R.string.tokens_in_out)
80+ // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
81+ // state-flip recomposition doesn't re-run NumberFormat + buildList +
82+ // joinToString for every visible assistant bubble.
83+ val costSummary = remember(info.isComplete, tokens, cost, tokenFormat) {
84+ if (!info.isComplete || (cost == null && tokens == null)) null
85+ else buildList {
8186 tokens?.let { add(formatTokens(it, tokenFormat)) }
8287 cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
83− }
84− if (parts.isNotEmpty()) {
85− Text(
86− parts.joinToString(" • "),
87− style = MaterialTheme.typography.labelSmall,
88− color = MaterialTheme.colorScheme.onSurfaceVariant,
89− )
90− }
88+ }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
89+ }
90+ costSummary?.let {
91+ Text(
92+ it,
93+ style = MaterialTheme.typography.labelSmall,
94+ color = MaterialTheme.colorScheme.onSurfaceVariant,
95+ )
9196 }
9297 }
9398 MessageTimestamp(message.info)
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+7−1
@@ -233,7 +233,13 @@ fun SessionListScreen(
233233 singleLine = true,
234234 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
235235 )
236− val sessions = remember(state) { state.filtered }
236+ val sessions = remember(state.sessions, state.query, state.previews) {
237+ // Fast-path an empty query: filtered just returns sessions, so skip
238+ // the recompute (and the fresh list reference) when only previews
239+ // churned in the background. With a non-empty query the previews
240+ // map affects the match set, so recompute on any of the three.
241+ if (state.query.isBlank()) state.sessions else state.filtered
242+ }
237243 if (sessions.isEmpty()) {
238244 Text(
239245 stringResource(R.string.no_sessions_match, state.query),
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+14−6
@@ -196,12 +196,20 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
196196 pendingSessionUpdates.clear()
197197 _state.update { s ->
198198 val byId = s.sessions.associateBy { it.id }.toMutableMap()
199− batch.forEach { (id, session) -> byId[id] = session }
200− s.copy(
201− sessions = byId.values
202− .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
203− .toList(),
204− )
199+ var anyChanged = false
200+ batch.forEach { (id, session) ->
201+ if (byId[id] != session) { byId[id] = session; anyChanged = true }
202+ }
203+ // No-op update: skip the rebuild entirely so the StateFlow doesn't emit a
204+ // new state (and trigger recomposition) when the batch held no real changes
205+ // (e.g. a duplicate SessionUpdated for an unchanged session).
206+ if (!anyChanged) return@update s
207+ val sorted = byId.values.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
208+ // If the new ordering matches the existing one (e.g. only the already-newest
209+ // session updated its timestamp), reuse the same list instance so downstream
210+ // skips a recomposition for an identical reference.
211+ val sessions = if (sorted == s.sessions) s.sessions else sorted
212+ s.copy(sessions = sessions)
205213 }
206214 batch.keys.forEach { loadPreview(it) }
207215 }