@@ -51,15 +51,23 @@ class OpencodeCore(
5151 private var reconnectAttempts = 0
5252 private var reconnectJob: Job? = null
5353
54+ // Guards against overlapping session refreshes: rapid taps launch multiple
55+ // loadSessions coroutines and the slowest-to-return would otherwise win.
56+ private var loadSessionsJob: Job? = null
57+ // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
58+ private val removedMessageIds = mutableSetOf<String>()
59+
5460 // ── Event entry point ──────────────────────────────────────────────────────
5561 fun dispatch(event: Event) {
5662 when (event) {
5763 // Store the field verbatim while typing; normalizing here would fight
5864 // the user's edits (e.g. eat the "://" as they type it). The URL is
5965 // normalized once, on Connect.
60− is Event.ServerUrlChanged -> { serverUrl = event.url; emit() }
61− is Event.UsernameChanged -> { username = event.value; emit() }
62− is Event.PasswordChanged -> { password = event.value; emit() }
66+ // Clear any prior error as soon as the user edits a field, so a stale
67+ // message (e.g. "Invalid credentials") doesn't linger while they correct it.
68+ is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
69+ is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
70+ is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
6371
6472 Event.Connect -> {
6573 serverUrl = normalizeUrl(serverUrl)
@@ -73,12 +81,21 @@ class OpencodeCore(
7381 scope.launch { probeHealth() }
7482 }
7583
76− Event.LoadSessions -> { loading = true; emit(); scope.launch { loadSessions() } }
84+ Event.LoadSessions -> {
85+ loading = true; emit()
86+ loadSessionsJob?.cancel()
87+ loadSessionsJob = scope.launch { loadSessions() }
88+ }
7789
7890 is Event.SelectSession -> {
79− currentSessionTitle = sessions.firstOrNull { it.id == event.id }?.title.orEmpty()
91+ // The session may have been deleted (e.g. via SSE) while the list was on
92+ // screen; opening it would only 404 on loadMessages. Bail with an error.
93+ val session = sessions.firstOrNull { it.id == event.id }
94+ if (session == null) { error = "Session no longer exists"; emit(); return }
95+ currentSessionTitle = session.title
8096 currentSessionId = event.id
8197 messages.clear()
98+ removedMessageIds.clear()
8299 screen = Screen.Chat
83100 loading = true
84101 generating = false
@@ -134,10 +151,12 @@ class OpencodeCore(
134151 screen = Screen.Sessions
135152 currentSessionId = null
136153 messages.clear()
154+ removedMessageIds.clear()
137155 generating = false
138156 loading = true
139157 emit()
140− scope.launch { loadSessions() }
158+ loadSessionsJob?.cancel()
159+ loadSessionsJob = scope.launch { loadSessions() }
141160 }
142161
143162 Event.NavigateToConnect -> {
@@ -147,6 +166,7 @@ class OpencodeCore(
147166 generating = false
148167 sessions.clear()
149168 messages.clear()
169+ removedMessageIds.clear()
150170 currentSessionId = null
151171 // Reset to the default so a late-firing close callback from the old
152172 // SSE connection can't flash the reconnecting banner on the next connect.
@@ -294,6 +314,7 @@ class OpencodeCore(
294314 loading = false
295315 if (resp.code in 200..299) {
296316 messages.clear()
317+ removedMessageIds.clear()
297318 messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
298319 generating = messages.any { it.streaming }
299320 } else {
@@ -367,6 +388,9 @@ class OpencodeCore(
367388 is Protocol.Event.MessageUpdated -> {
368389 val info = event.info
369390 if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
391+ // A MessageRemoved may have arrived before this (late/out-of-order) update;
392+ // don't re-create a message the server has already deleted.
393+ if (info.id in removedMessageIds) return false
370394 val streaming = info.time.completed == null && info.error == null
371395 val existing = messages.firstOrNull { it.id == info.id }
372396 if (existing != null) {
@@ -408,7 +432,11 @@ class OpencodeCore(
408432 return true
409433 }
410434
411− is Protocol.Event.MessageRemoved -> return messages.removeAll { it.id == event.messageId }
435+ is Protocol.Event.MessageRemoved -> {
436+ // Remember the ID so a late MessageUpdated can't resurrect the message.
437+ removedMessageIds.add(event.messageId)
438+ return messages.removeAll { it.id == event.messageId }
439+ }
412440
413441 is Protocol.Event.SessionIdle -> {
414442 if (!isCurrent(event.sessionId)) return false