git.iko.soy/opencode-android
on master help sign in
Fix stale error snackbar after successful retry, stale session title on server switch, and stale preview jobs surviving server switch
commit9703f0f7636458393e3ac249249a09caad945589 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 16:46 (3 months ago) parent85cf059
5 files changed+111−2
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+8−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+9 app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+54 app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+35 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+5−1
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+8−1
@@ -76,7 +76,13 @@ class ChatViewModel(
7676 .flatMapLatest { conn ->
7777 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
7878 }
79− .onEach { _loading.value = false }
79+ .onEach {
80+ _loading.value = false
81+ // Clear a stale error from a previous failed attempt now that
82+ // messages are flowing again. Without this, the error snackbar
83+ // persists after a transient failure that was retried successfully.
84+ if (_error.value != null) _error.value = null
85+ }
8086 .retryWhen { cause, _ ->
8187 _loading.value = false
8288 _error.value = container.friendlyError(cause)
@@ -259,6 +265,7 @@ class ChatViewModel(
259265 _error.value = null
260266 _failedDraft.value = null
261267 _selectedAgent.value = null
268+ _sessionTitle.value = null
262269 try {
263270 conn.events.events.collect { event ->
264271 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+9
@@ -119,6 +119,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
119119 container.activeConnection.collectLatest { conn ->
120120 if (conn == null) return@collectLatest
121121 _serverLabel.value = conn.profile.displayLabel
122+ // Cancel any in-flight preview loads from the previous connection so
123+ // they don't complete against the old server and write stale previews
124+ // for sessions that may not exist on the new one. collectLatest cancels
125+ // the SSE collector, but the jobs in livePreviewJobs are independent
126+ // launches on viewModelScope that survive the collector cancellation.
127+ synchronized(previewLock) {
128+ livePreviewJobs.values.forEach { it.cancel() }
129+ livePreviewJobs.clear()
130+ }
122131 // Reload the session list when a connection becomes available so the
123132 // initial load (which may have run before auto-reconnect finished and
124133 // found no connection) doesn't leave a stale "not connected" error.
app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+54
@@ -4,6 +4,7 @@ package soy.iko.opencode.ui.chat
44
55import kotlinx.coroutines.Dispatchers
66import kotlinx.coroutines.ExperimentalCoroutinesApi
7+import kotlinx.coroutines.launch
78import kotlinx.coroutines.test.StandardTestDispatcher
89import kotlinx.coroutines.test.TestScope
910import kotlinx.coroutines.test.resetMain
@@ -18,6 +19,7 @@ import org.junit.Assert.assertTrue
1819import org.junit.Before
1920import org.junit.Test
2021import soy.iko.opencode.data.model.Command
22+import soy.iko.opencode.data.model.MessageWithParts
2123import soy.iko.opencode.data.model.ModelInfo
2224import soy.iko.opencode.data.model.Permission
2325import soy.iko.opencode.data.model.PermissionResponse
@@ -30,6 +32,7 @@ import soy.iko.opencode.data.model.SessionDeleted
3032import soy.iko.opencode.data.model.SessionError
3133import soy.iko.opencode.data.model.SessionIdle
3234import soy.iko.opencode.data.model.SessionUpdated
35+import soy.iko.opencode.data.model.UserMessage
3336import soy.iko.opencode.data.network.EventStreamClient
3437import soy.iko.opencode.ui.testing.FakeAppContainer
3538import soy.iko.opencode.ui.testing.FakeEventStreamClient
@@ -478,4 +481,55 @@ class ChatViewModelTest {
478481 vm.clearError()
479482 assertNull(vm.error.value)
480483 }
484+
485+ @Test
486+ fun messagesFlowRetry_clearsStaleErrorOnSuccess() = testScope.runTest {
487+ val api = FakeOpencodeApiClient()
488+ val events = FakeEventStreamClient()
489+ // Use a controllable flow so we can simulate a re-emission after an error.
490+ val messagesFlow = kotlinx.coroutines.flow.MutableStateFlow(
491+ listOf(MessageWithParts(info = UserMessage(id = "m1", sessionID = "session1")))
492+ )
493+ val repo = FakeSessionRepository(api, events)
494+ repo.observeMessagesOverride = messagesFlow
495+ val container = makeContainer(api = api, events = events, repo = repo)
496+ val vm = makeVm(container)
497+ // Subscribe to messages so the flow's onEach is active and can clear errors.
498+ val collector = launch { vm.messages.collect { /* keep subscribed */ } }
499+ testScope.testScheduler.advanceUntilIdle()
500+ // Set an error via SSE
501+ events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
502+ testScope.testScheduler.advanceUntilIdle()
503+ assertNotNull(vm.error.value)
504+ // Trigger a new messages emission — onEach should clear the stale error
505+ messagesFlow.value = messagesFlow.value + MessageWithParts(
506+ info = UserMessage(id = "m2", sessionID = "session1"),
507+ )
508+ testScope.testScheduler.advanceUntilIdle()
509+ assertNull(vm.error.value)
510+ collector.cancel()
511+ }
512+
513+ // --- Connection switch clears session title ---
514+
515+ @Test
516+ fun connectionChange_clearsSessionTitle() = testScope.runTest {
517+ val events = FakeEventStreamClient()
518+ val container = makeContainer(events = events)
519+ val vm = makeVm(container)
520+ // Set the title via SSE event
521+ events.fakeEvents.tryEmit(
522+ SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "Old Title"))),
523+ )
524+ testScope.testScheduler.advanceUntilIdle()
525+ assertEquals("Old Title", vm.sessionTitle.value)
526+ // Switch to a new connection
527+ val api2 = FakeOpencodeApiClient()
528+ val events2 = FakeEventStreamClient()
529+ val repo2 = FakeSessionRepository(api2, events2)
530+ val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
531+ container.setActiveConnection(conn2)
532+ testScope.testScheduler.advanceUntilIdle()
533+ assertNull(vm.sessionTitle.value)
534+ }
481535}
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+35
@@ -280,4 +280,39 @@ class SessionListViewModelTest {
280280 state,
281281 )
282282 }
283+
284+ // --- Server switch cancels stale preview jobs ---
285+
286+ @Test
287+ fun connectionChange_cancelsStalePreviewJobs() = testScope.runTest {
288+ val api = FakeOpencodeApiClient()
289+ val events = FakeEventStreamClient()
290+ val repo = FakeSessionRepository(api, events)
291+ repo.sessions = listOf(Session(id = "s1", title = "Chat 1"))
292+ api.messages = listOf(
293+ MessageWithParts(
294+ info = UserMessage(id = "m1", sessionID = "s1"),
295+ parts = listOf(TextPart(id = "p1", text = "hello")),
296+ ),
297+ )
298+ val container = makeContainer(api = api, events = events, repo = repo)
299+ val vm = makeVm(container)
300+ // Trigger a preview load via SSE event
301+ events.fakeEvents.tryEmit(
302+ SessionUpdated(SessionUpdated.Props(info = Session(id = "s1", title = "Chat 1"))),
303+ )
304+ testScheduler.advanceUntilIdle()
305+ // Preview should be loaded
306+ assertEquals("hello", vm.state.value.previews["s1"])
307+ // Switch to a new connection — previews should be cleared by refresh()
308+ val api2 = FakeOpencodeApiClient()
309+ val events2 = FakeEventStreamClient()
310+ val repo2 = FakeSessionRepository(api2, events2)
311+ repo2.sessions = listOf(Session(id = "x1", title = "New Server Chat"))
312+ val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
313+ container.setActiveConnection(conn2)
314+ testScheduler.advanceUntilIdle()
315+ // The old session's preview should be gone (refresh re-populates from the new server)
316+ assertFalse(vm.state.value.previews.containsKey("s1"))
317+ }
283318}
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+5−1
@@ -226,6 +226,9 @@ class FakeSessionRepository(
226226 var abortCalls: List<String> = emptyList()
227227 private set
228228
229+ /** When non-null, [observeMessages] returns this flow instead of [messages]. */
230+ var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
231+
229232 override suspend fun listSessions(): List<Session> {
230233 listSessionsThrows?.let { throw it }
231234 return sessions
@@ -257,7 +260,8 @@ class FakeSessionRepository(
257260 parts = emptyList(),
258261 )
259262
260− override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = flowOf(messages)
263+ override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> =
264+ observeMessagesOverride ?: flowOf(messages)
261265}
262266
263267// ---------------------------------------------------------------------------