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
9703f0fiko
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+8−1
@@ -76,7 +76,13 @@ class ChatViewModel(
76 .flatMapLatest { conn ->
76 .flatMapLatest { conn ->
77 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
77 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
78 }
78 }
79+ .onEach {
79− .onEach { _loading.value = false }
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+ }
80 .retryWhen { cause, _ ->
86 .retryWhen { cause, _ ->
81 _loading.value = false
87 _loading.value = false
82 _error.value = container.friendlyError(cause)
88 _error.value = container.friendlyError(cause)
@@ -259,6 +265,7 @@ class ChatViewModel(
259 _error.value = null
265 _error.value = null
260 _failedDraft.value = null
266 _failedDraft.value = null
261 _selectedAgent.value = null
267 _selectedAgent.value = null
268+ _sessionTitle.value = null
262 try {
269 try {
263 conn.events.events.collect { event ->
270 conn.events.events.collect { event ->
264 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
271 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() {
119 container.activeConnection.collectLatest { conn ->
119 container.activeConnection.collectLatest { conn ->
120 if (conn == null) return@collectLatest
120 if (conn == null) return@collectLatest
121 _serverLabel.value = conn.profile.displayLabel
121 _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+ }
122 // Reload the session list when a connection becomes available so the
131 // Reload the session list when a connection becomes available so the
123 // initial load (which may have run before auto-reconnect finished and
132 // initial load (which may have run before auto-reconnect finished and
124 // found no connection) doesn't leave a stale "not connected" error.
133 // 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
4
4
5import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.ExperimentalCoroutinesApi
6import kotlinx.coroutines.ExperimentalCoroutinesApi
7+import kotlinx.coroutines.launch
7import kotlinx.coroutines.test.StandardTestDispatcher
8import kotlinx.coroutines.test.StandardTestDispatcher
8import kotlinx.coroutines.test.TestScope
9import kotlinx.coroutines.test.TestScope
9import kotlinx.coroutines.test.resetMain
10import kotlinx.coroutines.test.resetMain
@@ -18,6 +19,7 @@ import org.junit.Assert.assertTrue
18import org.junit.Before
19import org.junit.Before
19import org.junit.Test
20import org.junit.Test
20import soy.iko.opencode.data.model.Command
21import soy.iko.opencode.data.model.Command
22+import soy.iko.opencode.data.model.MessageWithParts
21import soy.iko.opencode.data.model.ModelInfo
23import soy.iko.opencode.data.model.ModelInfo
22import soy.iko.opencode.data.model.Permission
24import soy.iko.opencode.data.model.Permission
23import soy.iko.opencode.data.model.PermissionResponse
25import soy.iko.opencode.data.model.PermissionResponse
@@ -30,6 +32,7 @@ import soy.iko.opencode.data.model.SessionDeleted
30import soy.iko.opencode.data.model.SessionError
32import soy.iko.opencode.data.model.SessionError
31import soy.iko.opencode.data.model.SessionIdle
33import soy.iko.opencode.data.model.SessionIdle
32import soy.iko.opencode.data.model.SessionUpdated
34import soy.iko.opencode.data.model.SessionUpdated
35+import soy.iko.opencode.data.model.UserMessage
33import soy.iko.opencode.data.network.EventStreamClient
36import soy.iko.opencode.data.network.EventStreamClient
34import soy.iko.opencode.ui.testing.FakeAppContainer
37import soy.iko.opencode.ui.testing.FakeAppContainer
35import soy.iko.opencode.ui.testing.FakeEventStreamClient
38import soy.iko.opencode.ui.testing.FakeEventStreamClient
@@ -478,4 +481,55 @@ class ChatViewModelTest {
478 vm.clearError()
481 vm.clearError()
479 assertNull(vm.error.value)
482 assertNull(vm.error.value)
480 }
483 }
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+ }
481}
535}
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+35
@@ -280,4 +280,39 @@ class SessionListViewModelTest {
280 state,
280 state,
281 )
281 )
282 }
282 }
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+ }
283}
318}
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+5−1
@@ -226,6 +226,9 @@ class FakeSessionRepository(
226 var abortCalls: List<String> = emptyList()
226 var abortCalls: List<String> = emptyList()
227 private set
227 private set
228
228
229+ /** When non-null, [observeMessages] returns this flow instead of [messages]. */
230+ var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
231+
229 override suspend fun listSessions(): List<Session> {
232 override suspend fun listSessions(): List<Session> {
230 listSessionsThrows?.let { throw it }
233 listSessionsThrows?.let { throw it }
231 return sessions
234 return sessions
@@ -257,7 +260,8 @@ class FakeSessionRepository(
257 parts = emptyList(),
260 parts = emptyList(),
258 )
261 )
259
262
260− override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = flowOf(messages)
263+ override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> =
264+ observeMessagesOverride ?: flowOf(messages)
261}
265}
262
266
263// ---------------------------------------------------------------------------
267// ---------------------------------------------------------------------------