@@ -51,15 +51,23 @@ class OpencodeCore(
51 private var reconnectAttempts = 0
51 private var reconnectAttempts = 0
52 private var reconnectJob: Job? = null
52 private var reconnectJob: Job? = null
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>()
54 // ── Event entry point ──────────────────────────────────────────────────────
60 // ── Event entry point ──────────────────────────────────────────────────────
55 fun dispatch(event: Event) {
61 fun dispatch(event: Event) {
56 when (event) {
62 when (event) {
57 // Store the field verbatim while typing; normalizing here would fight
63 // Store the field verbatim while typing; normalizing here would fight
58 // the user's edits (e.g. eat the "://" as they type it). The URL is
64 // the user's edits (e.g. eat the "://" as they type it). The URL is
59 // normalized once, on Connect.
65 // normalized once, on Connect.
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.
60− is Event.ServerUrlChanged -> { serverUrl = event.url; emit() }
68+ is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
61− is Event.UsernameChanged -> { username = event.value; emit() }
69+ is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
62− is Event.PasswordChanged -> { password = event.value; emit() }
70+ is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
64 Event.Connect -> {
72 Event.Connect -> {
65 serverUrl = normalizeUrl(serverUrl)
73 serverUrl = normalizeUrl(serverUrl)
@@ -73,12 +81,21 @@ class OpencodeCore(
73 scope.launch { probeHealth() }
81 scope.launch { probeHealth() }
84+ Event.LoadSessions -> {
85+ loading = true; emit()
86+ loadSessionsJob?.cancel()
76− Event.LoadSessions -> { loading = true; emit(); scope.launch { loadSessions() } }
87+ loadSessionsJob = scope.launch { loadSessions() }
78 is Event.SelectSession -> {
90 is Event.SelectSession -> {
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.
79− currentSessionTitle = sessions.firstOrNull { it.id == event.id }?.title.orEmpty()
93+ val session = sessions.firstOrNull { it.id == event.id }
94+ if (session == null) { error = "Session no longer exists"; emit(); return }
95+ currentSessionTitle = session.title
80 currentSessionId = event.id
96 currentSessionId = event.id
81 messages.clear()
97 messages.clear()
98+ removedMessageIds.clear()
82 screen = Screen.Chat
99 screen = Screen.Chat
83 loading = true
100 loading = true
84 generating = false
101 generating = false
@@ -134,10 +151,12 @@ class OpencodeCore(
134 screen = Screen.Sessions
151 screen = Screen.Sessions
135 currentSessionId = null
152 currentSessionId = null
136 messages.clear()
153 messages.clear()
154+ removedMessageIds.clear()
137 generating = false
155 generating = false
138 loading = true
156 loading = true
158+ loadSessionsJob?.cancel()
140− scope.launch { loadSessions() }
159+ loadSessionsJob = scope.launch { loadSessions() }
143 Event.NavigateToConnect -> {
162 Event.NavigateToConnect -> {
@@ -147,6 +166,7 @@ class OpencodeCore(
147 generating = false
166 generating = false
148 sessions.clear()
167 sessions.clear()
149 messages.clear()
168 messages.clear()
169+ removedMessageIds.clear()
150 currentSessionId = null
170 currentSessionId = null
151 // Reset to the default so a late-firing close callback from the old
171 // Reset to the default so a late-firing close callback from the old
152 // SSE connection can't flash the reconnecting banner on the next connect.
172 // SSE connection can't flash the reconnecting banner on the next connect.
@@ -294,6 +314,7 @@ class OpencodeCore(
294 loading = false
314 loading = false
295 if (resp.code in 200..299) {
315 if (resp.code in 200..299) {
296 messages.clear()
316 messages.clear()
317+ removedMessageIds.clear()
297 messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
318 messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
298 generating = messages.any { it.streaming }
319 generating = messages.any { it.streaming }
@@ -367,6 +388,9 @@ class OpencodeCore(
367 is Protocol.Event.MessageUpdated -> {
388 is Protocol.Event.MessageUpdated -> {
368 val info = event.info
389 val info = event.info
369 if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
390 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
370 val streaming = info.time.completed == null && info.error == null
394 val streaming = info.time.completed == null && info.error == null
371 val existing = messages.firstOrNull { it.id == info.id }
395 val existing = messages.firstOrNull { it.id == info.id }
372 if (existing != null) {
396 if (existing != null) {
@@ -408,7 +432,11 @@ class OpencodeCore(
408 return true
432 return true
435+ is Protocol.Event.MessageRemoved -> {
436+ // Remember the ID so a late MessageUpdated can't resurrect the message.
437+ removedMessageIds.add(event.messageId)
411− is Protocol.Event.MessageRemoved -> return messages.removeAll { it.id == event.messageId }
438+ return messages.removeAll { it.id == event.messageId }
413 is Protocol.Event.SessionIdle -> {
441 is Protocol.Event.SessionIdle -> {
414 if (!isCurrent(event.sessionId)) return false
442 if (!isCurrent(event.sessionId)) return false