git.iko.soy/opencode-android
on master help sign in
Cut redundant network fetches and halve lock contention on the streaming hot path
2589ee4iko
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+12
@@ -183,6 +183,18 @@ open class OpencodeApiClient private constructor(
183 commandsMutex.withLock { cachedCommands = null }
183 commandsMutex.withLock { cachedCommands = null }
184 }
184 }
185
185
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+
186 /** Respond to a permission request so a paused tool run can proceed. */
198 /** Respond to a permission request so a paused tool run can proceed. */
187 open suspend fun respondPermission(
199 open suspend fun respondPermission(
188 sessionId: String,
200 sessionId: String,
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+8−2
@@ -69,8 +69,14 @@ open class SessionRepository(
69 // Subscribe to events first so we don't miss early deltas during the initial load.
69 // Subscribe to events first so we don't miss early deltas during the initial load.
70 val job = launch {
70 val job = launch {
71 eventStream.events.collect { event ->
71 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 {
72− val changed = lock.withLock { store.reduce(sessionId, event) }
77+ if (store.reduce(sessionId, event)) store.snapshot() else null
73− if (changed) publish()
78+ }
79+ if (snapshot != null) send(snapshot)
74 }
80 }
75 }
81 }
76
82
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+7−3
@@ -189,7 +189,7 @@ class ChatViewModel(
189 fun reloadModels() {
189 fun reloadModels() {
190 val conn = connection ?: return
190 val conn = connection ?: return
191 viewModelScope.launch {
191 viewModelScope.launch {
192− conn.api.invalidateCache()
192+ conn.api.invalidateProvidersCache()
193 _modelsReload.value++
193 _modelsReload.value++
194 }
194 }
195 }
195 }
@@ -197,7 +197,7 @@ class ChatViewModel(
197 fun reloadAgents() {
197 fun reloadAgents() {
198 val conn = connection ?: return
198 val conn = connection ?: return
199 viewModelScope.launch {
199 viewModelScope.launch {
200− conn.api.invalidateCache()
200+ conn.api.invalidateAgentsCache()
201 _agentsReload.value++
201 _agentsReload.value++
202 }
202 }
203 }
203 }
@@ -205,7 +205,7 @@ class ChatViewModel(
205 fun reloadCommands() {
205 fun reloadCommands() {
206 val conn = connection ?: return
206 val conn = connection ?: return
207 viewModelScope.launch {
207 viewModelScope.launch {
208− conn.api.invalidateCache()
208+ conn.api.invalidateCommandsCache()
209 _commandsReload.value++
209 _commandsReload.value++
210 }
210 }
211 }
211 }
@@ -337,9 +337,13 @@ class ChatViewModel(
337 reloadTrigger = _commandsReload,
337 reloadTrigger = _commandsReload,
338 )
338 )
339 // Resolve the human-readable session title for the app bar (non-fatal).
339 // 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.
340 viewModelScope.launch {
343 viewModelScope.launch {
341 container.activeConnection.collectLatest { conn ->
344 container.activeConnection.collectLatest { conn ->
342 if (conn == null) return@collectLatest
345 if (conn == null) return@collectLatest
346+ if (_sessionTitle.value != null) return@collectLatest
343 runCatchingCancellable { conn.repository.listSessions() }
347 runCatchingCancellable { conn.repository.listSessions() }
344 .getOrNull()
348 .getOrNull()
345 ?.firstOrNull { it.id == sessionId }
349 ?.firstOrNull { it.id == sessionId }
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+7−3
@@ -89,10 +89,14 @@ fun MarkdownText(
89 .conflate()
89 .conflate()
90 .collect { md ->
90 .collect { md ->
91 // Only throttle when the content is growing incrementally (streaming):
91 // 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.
93 if (renderedContent.isNotEmpty() &&
98 if (renderedContent.isNotEmpty() &&
94− md.startsWith(renderedContent) &&
95− md != renderedContent
99+ md.length > renderedContent.length
96 ) {
100 ) {
97 delay(NetworkConfig.streamingThrottleMs)
101 delay(NetworkConfig.streamingThrottleMs)
98 }
102 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+9−2
@@ -255,7 +255,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
255 }
255 }
256 }
256 }
257
257
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. */
259 private fun loadPreviews(sessions: List<Session>) {
263 private fun loadPreviews(sessions: List<Session>) {
260 previewJob?.cancel()
264 previewJob?.cancel()
261 val conn = container.activeConnection.value ?: return
265 val conn = container.activeConnection.value ?: return
@@ -264,9 +268,12 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
264 // clearing the whole map (which would cause a visual flash).
268 // clearing the whole map (which would cause a visual flash).
265 val keepIds = sessions.mapTo(mutableSetOf()) { it.id }
269 val keepIds = sessions.mapTo(mutableSetOf()) { it.id }
266 _state.update { s -> s.copy(previews = s.previews.filterKeys { it in keepIds }) }
270 _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
267 previewJob = viewModelScope.launch {
274 previewJob = viewModelScope.launch {
268 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
275 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
269− sessions.take(NetworkConfig.maxPreviewSessions).map { session ->
276+ toFetch.map { session ->
270 launch {
277 launch {
271 semaphore.withPermit {
278 semaphore.withPermit {
272 val preview = fetchSessionPreview(api, session.id) ?: return@withPermit
279 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 {
408 val container = makeContainer(api = api)
408 val container = makeContainer(api = api)
409 val vm = makeVm(container)
409 val vm = makeVm(container)
410 testScope.testScheduler.advanceUntilIdle()
410 testScope.testScheduler.advanceUntilIdle()
411− assertFalse(api.cacheInvalidated)
411+ assertFalse(api.providersCacheInvalidated)
412
412
413 api.providersResponse = ProvidersResponse(
413 api.providersResponse = ProvidersResponse(
414 providers = listOf(
414 providers = listOf(
@@ -417,7 +417,7 @@ class ChatViewModelTest {
417 )
417 )
418 vm.reloadModels()
418 vm.reloadModels()
419 testScope.testScheduler.advanceUntilIdle()
419 testScope.testScheduler.advanceUntilIdle()
420− assertTrue(api.cacheInvalidated)
420+ assertTrue(api.providersCacheInvalidated)
421 assertTrue(vm.models.value.isNotEmpty())
421 assertTrue(vm.models.value.isNotEmpty())
422 }
422 }
423
423
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+33
@@ -425,4 +425,37 @@ class SessionListViewModelTest {
425 // The old session's preview should be gone (refresh re-populates from the new server)
425 // The old session's preview should be gone (refresh re-populates from the new server)
426 assertFalse(vm.state.value.previews.containsKey("s1"))
426 assertFalse(vm.state.value.previews.containsKey("s1"))
427 }
427 }
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+ }
428}
461}
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+16−1
@@ -120,6 +120,12 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
120 var sendPromptThrows: Throwable? = null
120 var sendPromptThrows: Throwable? = null
121 var respondPermissionThrows: Throwable? = null
121 var respondPermissionThrows: Throwable? = null
122 var cacheInvalidated = false
122 var cacheInvalidated = false
123+ var providersCacheInvalidated = false
124+ private set
125+ var agentsCacheInvalidated = false
126+ private set
127+ var commandsCacheInvalidated = false
128+ private set
123
129
124 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
130 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
125 private set
131 private set
@@ -144,7 +150,13 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
144 deletedSessions = deletedSessions + id
150 deletedSessions = deletedSessions + id
145 }
151 }
146
152
153+ var listMessagesCalls: List<String> = emptyList()
154+ private set
155+
147− override suspend fun listMessages(sessionId: String): List<MessageWithParts> = messages
156+ override suspend fun listMessages(sessionId: String): List<MessageWithParts> {
157+ listMessagesCalls = listMessagesCalls + sessionId
158+ return messages
159+ }
148
160
149 override suspend fun sendPrompt(
161 override suspend fun sendPrompt(
150 sessionId: String,
162 sessionId: String,
@@ -180,6 +192,9 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
180 override suspend fun commands(): List<Command> = commandsList
192 override suspend fun commands(): List<Command> = commandsList
181
193
182 override suspend fun invalidateCache() { cacheInvalidated = true }
194 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 }
183
198
184 override suspend fun respondPermission(
199 override suspend fun respondPermission(
185 sessionId: String,
200 sessionId: String,