git.iko.soy/opencode-android
on master help sign in
Cut redundant network fetches and halve lock contention on the streaming hot path
commit2589ee449681e9f5eea0323aea5c9cefb9cf7cdc authoriko <mail@iko.soy> dateWed 1 Jul 2026, 00:44 (3 months ago) parent34c5c50
8 files changed+94−13
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+12 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+8−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+7−3 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+7−3 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+9−2 app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+2−2 app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+33 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+16−1
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+12
@@ -183,6 +183,18 @@ open class OpencodeApiClient private constructor(
183183 commandsMutex.withLock { cachedCommands = null }
184184 }
185185
186+ open suspend fun invalidateProvidersCache() {
187+ providersMutex.withLock { cachedProviders = null }
188+ }
189+
190+ open suspend fun invalidateAgentsCache() {
191+ agentsMutex.withLock { cachedAgents = null }
192+ }
193+
194+ open suspend fun invalidateCommandsCache() {
195+ commandsMutex.withLock { cachedCommands = null }
196+ }
197+
186198 /** Respond to a permission request so a paused tool run can proceed. */
187199 open suspend fun respondPermission(
188200 sessionId: String,
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+8−2
@@ -69,8 +69,14 @@ open class SessionRepository(
6969 // Subscribe to events first so we don't miss early deltas during the initial load.
7070 val job = launch {
7171 eventStream.events.collect { event ->
72− val changed = lock.withLock { store.reduce(sessionId, event) }
73− if (changed) publish()
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)
7480 }
7581 }
7682
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+7−3
@@ -189,7 +189,7 @@ class ChatViewModel(
189189 fun reloadModels() {
190190 val conn = connection ?: return
191191 viewModelScope.launch {
192− conn.api.invalidateCache()
192+ conn.api.invalidateProvidersCache()
193193 _modelsReload.value++
194194 }
195195 }
@@ -197,7 +197,7 @@ class ChatViewModel(
197197 fun reloadAgents() {
198198 val conn = connection ?: return
199199 viewModelScope.launch {
200− conn.api.invalidateCache()
200+ conn.api.invalidateAgentsCache()
201201 _agentsReload.value++
202202 }
203203 }
@@ -205,7 +205,7 @@ class ChatViewModel(
205205 fun reloadCommands() {
206206 val conn = connection ?: return
207207 viewModelScope.launch {
208− conn.api.invalidateCache()
208+ conn.api.invalidateCommandsCache()
209209 _commandsReload.value++
210210 }
211211 }
@@ -337,9 +337,13 @@ class ChatViewModel(
337337 reloadTrigger = _commandsReload,
338338 )
339339 // Resolve the human-readable session title for the app bar (non-fatal).
340+ // Skips the REST call when the SSE event collector already delivered the title
341+ // (SessionUpdated fires shortly after connect), avoiding a redundant full
342+ // session-list download.
340343 viewModelScope.launch {
341344 container.activeConnection.collectLatest { conn ->
342345 if (conn == null) return@collectLatest
346+ if (_sessionTitle.value != null) return@collectLatest
343347 runCatchingCancellable { conn.repository.listSessions() }
344348 .getOrNull()
345349 ?.firstOrNull { it.id == sessionId }
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+7−3
@@ -89,10 +89,14 @@ fun MarkdownText(
8989 .conflate()
9090 .collect { md ->
9191 // Only throttle when the content is growing incrementally (streaming):
92− // a content switch or initial render proceeds immediately.
92+ // a content switch or initial render proceeds immediately. Checking
93+ // length is O(1) vs. startsWith which is O(n) in the rendered content
94+ // length — during a long streaming response this runs every throttle
95+ // cycle. The keyed LaunchedEffect(markdown.take(32)) already handles
96+ // content switches; within one keyed cycle, growing length means
97+ // appending (streaming), not a rewrite.
9398 if (renderedContent.isNotEmpty() &&
94− md.startsWith(renderedContent) &&
95− md != renderedContent
99+ md.length > renderedContent.length
96100 ) {
97101 delay(NetworkConfig.streamingThrottleMs)
98102 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+9−2
@@ -255,7 +255,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
255255 }
256256 }
257257
258− /** Fetch the last text part of each session for list previews (best-effort, bounded). */
258+ /** Fetch the last text part of each session for list previews (best-effort, bounded).
259+ * Sessions that already have a cached preview are skipped — their previews are kept
260+ * fresh by the live SSE handler ([loadPreview] on [SessionUpdated]). Each preview
261+ * fetch downloads the full conversation history, so re-fetching all of them on every
262+ * refresh is wasteful when most are already cached. */
259263 private fun loadPreviews(sessions: List<Session>) {
260264 previewJob?.cancel()
261265 val conn = container.activeConnection.value ?: return
@@ -264,9 +268,12 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
264268 // clearing the whole map (which would cause a visual flash).
265269 val keepIds = sessions.mapTo(mutableSetOf()) { it.id }
266270 _state.update { s -> s.copy(previews = s.previews.filterKeys { it in keepIds }) }
271+ val cached = _state.value.previews.keys
272+ val toFetch = sessions.take(NetworkConfig.maxPreviewSessions).filter { it.id !in cached }
273+ if (toFetch.isEmpty()) return
267274 previewJob = viewModelScope.launch {
268275 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
269− sessions.take(NetworkConfig.maxPreviewSessions).map { session ->
276+ toFetch.map { session ->
270277 launch {
271278 semaphore.withPermit {
272279 val preview = fetchSessionPreview(api, session.id) ?: return@withPermit
app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+2−2
@@ -408,7 +408,7 @@ class ChatViewModelTest {
408408 val container = makeContainer(api = api)
409409 val vm = makeVm(container)
410410 testScope.testScheduler.advanceUntilIdle()
411− assertFalse(api.cacheInvalidated)
411+ assertFalse(api.providersCacheInvalidated)
412412
413413 api.providersResponse = ProvidersResponse(
414414 providers = listOf(
@@ -417,7 +417,7 @@ class ChatViewModelTest {
417417 )
418418 vm.reloadModels()
419419 testScope.testScheduler.advanceUntilIdle()
420− assertTrue(api.cacheInvalidated)
420+ assertTrue(api.providersCacheInvalidated)
421421 assertTrue(vm.models.value.isNotEmpty())
422422 }
423423
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+33
@@ -425,4 +425,37 @@ class SessionListViewModelTest {
425425 // The old session's preview should be gone (refresh re-populates from the new server)
426426 assertFalse(vm.state.value.previews.containsKey("s1"))
427427 }
428+
429+ // --- Preview caching ---
430+
431+ @Test
432+ fun refresh_skipsPreviewsAlreadyCached() = testScope.runTest {
433+ val api = FakeOpencodeApiClient()
434+ val events = FakeEventStreamClient()
435+ val repo = FakeSessionRepository(api, events)
436+ repo.sessions = listOf(
437+ Session(id = "s1", title = "Chat 1"),
438+ Session(id = "s2", title = "Chat 2"),
439+ )
440+ api.messages = listOf(
441+ MessageWithParts(
442+ info = UserMessage(id = "m1", sessionID = "s1"),
443+ parts = listOf(TextPart(id = "p1", text = "hello")),
444+ ),
445+ )
446+ val container = makeContainer(api = api, events = events, repo = repo)
447+ val vm = makeVm(container)
448+ // Initial load fetches previews for both sessions.
449+ val initialCalls = api.listMessagesCalls.size
450+ assertTrue("initial load should fetch previews", initialCalls > 0)
451+
452+ // A second refresh should NOT re-fetch previews already cached.
453+ vm.refresh()
454+ testScheduler.advanceUntilIdle()
455+ assertEquals(
456+ "refresh should skip cached previews",
457+ initialCalls,
458+ api.listMessagesCalls.size,
459+ )
460+ }
428461}
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+16−1
@@ -120,6 +120,12 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
120120 var sendPromptThrows: Throwable? = null
121121 var respondPermissionThrows: Throwable? = null
122122 var cacheInvalidated = false
123+ var providersCacheInvalidated = false
124+ private set
125+ var agentsCacheInvalidated = false
126+ private set
127+ var commandsCacheInvalidated = false
128+ private set
123129
124130 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
125131 private set
@@ -144,7 +150,13 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
144150 deletedSessions = deletedSessions + id
145151 }
146152
147− override suspend fun listMessages(sessionId: String): List<MessageWithParts> = messages
153+ var listMessagesCalls: List<String> = emptyList()
154+ private set
155+
156+ override suspend fun listMessages(sessionId: String): List<MessageWithParts> {
157+ listMessagesCalls = listMessagesCalls + sessionId
158+ return messages
159+ }
148160
149161 override suspend fun sendPrompt(
150162 sessionId: String,
@@ -180,6 +192,9 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
180192 override suspend fun commands(): List<Command> = commandsList
181193
182194 override suspend fun invalidateCache() { cacheInvalidated = true }
195+ override suspend fun invalidateProvidersCache() { providersCacheInvalidated = true }
196+ override suspend fun invalidateAgentsCache() { agentsCacheInvalidated = true }
197+ override suspend fun invalidateCommandsCache() { commandsCacheInvalidated = true }
183198
184199 override suspend fun respondPermission(
185200 sessionId: String,