@@ -1,6 +1,7 @@
1package soy.iko.opencode.core
1package soy.iko.opencode.core
3import kotlinx.coroutines.CoroutineScope
3import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.Job
4import kotlinx.coroutines.delay
5import kotlinx.coroutines.delay
5import kotlinx.coroutines.flow.MutableStateFlow
6import kotlinx.coroutines.flow.MutableStateFlow
6import kotlinx.coroutines.flow.StateFlow
7import kotlinx.coroutines.flow.StateFlow
@@ -29,7 +30,6 @@ class OpencodeCore(
29 private var username = ""
30 private var username = ""
30 private var password = ""
31 private var password = ""
31 private var authRequired = false
32 private var authRequired = false
32− private var authed = false
33 private var connected = false
33 private var connected = false
34 private var loading = false
34 private var loading = false
35 private var error: String? = null
35 private var error: String? = null
@@ -40,6 +40,7 @@ class OpencodeCore(
40 private val messages = mutableListOf<MsgState>()
40 private val messages = mutableListOf<MsgState>()
41 private var draft = ""
41 private var draft = ""
42 private var generating = false
42 private var generating = false
43+ private var sseConnected = true
43 private var localSeq = 0L
44 private var localSeq = 0L
45 private val _view = MutableStateFlow(snapshot())
46 private val _view = MutableStateFlow(snapshot())
@@ -47,6 +48,32 @@ class OpencodeCore(
48 private var sse: SseClient? = null
49 private var sse: SseClient? = null
49 private var sseClosedIntentionally = false
50 private var sseClosedIntentionally = false
51+ private var reconnectAttempts = 0
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+ // Tracks the in-flight createSession coroutine so navigating away can cancel it
58+ // before it dispatches SelectSession into a screen the user already left.
59+ private var createSessionJob: Job? = null
60+ // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61+ // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62+ private var loadMessagesJob: Job? = null
63+ // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
64+ // otherwise launch two concurrent health probes; cancel the prior one first.
65+ private var connectJob: Job? = null
66+ // Tracks the in-flight DeleteSession coroutine so navigating away can cancel it
67+ // before its response handler mutates the sessions list / screen the user already left.
68+ private var deleteSessionJob: Job? = null
69+ // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
70+ // can cancel it before its response handler touches stale message state.
71+ private var sendMessageJob: Job? = null
72+ // Tracks the in-flight abort coroutine (CancelGeneration) so rapid cancel taps don't
73+ // fire multiple abort requests, and navigating away can cancel it.
74+ private var abortJob: Job? = null
75+ // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
76+ private val removedMessageIds = mutableSetOf<String>()
51 // ── Event entry point ──────────────────────────────────────────────────────
78 // ── Event entry point ──────────────────────────────────────────────────────
52 fun dispatch(event: Event) {
79 fun dispatch(event: Event) {
@@ -54,41 +81,90 @@ class OpencodeCore(
54 // Store the field verbatim while typing; normalizing here would fight
81 // Store the field verbatim while typing; normalizing here would fight
55 // the user's edits (e.g. eat the "://" as they type it). The URL is
82 // the user's edits (e.g. eat the "://" as they type it). The URL is
56 // normalized once, on Connect.
83 // normalized once, on Connect.
84+ // Clear any prior error as soon as the user edits a field, so a stale
85+ // message (e.g. "Invalid credentials") doesn't linger while they correct it.
57− is Event.ServerUrlChanged -> { serverUrl = event.url; emit() }
86+ is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
58− is Event.UsernameChanged -> { username = event.value; emit() }
87+ is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
59− is Event.PasswordChanged -> { password = event.value; emit() }
88+ is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
61 Event.Connect -> {
90 Event.Connect -> {
62 serverUrl = normalizeUrl(serverUrl)
91 serverUrl = normalizeUrl(serverUrl)
63 if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
92 if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
93+ // The auth fields are showing but empty — sending the request now
94+ // would just 401 again in a loop. Prompt for credentials instead.
95+ if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
96+ // Leave authRequired as-is; probeHealth sets it from the response (true on
97+ // 401, false on success). Resetting it here just flickers the auth fields.
64− loading = true; error = null; authRequired = false; emit()
98+ loading = true; error = null; emit()
65− scope.launch { probeHealth() }
100+ connectJob = scope.launch { probeHealth() }
103+ Event.LoadSessions -> {
104+ if (!connected) { error = "Not connected"; emit(); return }
105+ loading = true; emit()
106+ loadSessionsJob?.cancel()
68− Event.LoadSessions -> { loading = true; emit(); scope.launch { loadSessions() } }
107+ loadSessionsJob = scope.launch { loadSessions() }
70 is Event.SelectSession -> {
110 is Event.SelectSession -> {
111+ // The session may have been deleted (e.g. via SSE) while the list was on
112+ // screen; opening it would only 404 on loadMessages. Bail with an error.
71− currentSessionTitle = sessions.firstOrNull { it.id == event.id }?.title.orEmpty()
113+ val session = sessions.firstOrNull { it.id == event.id }
114+ if (session == null) { error = "Session no longer exists"; emit(); return }
115+ currentSessionTitle = session.title
72 currentSessionId = event.id
116 currentSessionId = event.id
73 messages.clear()
117 messages.clear()
118+ removedMessageIds.clear()
74 screen = Screen.Chat
119 screen = Screen.Chat
75 loading = true
120 loading = true
76 error = null
122 error = null
124+ connectJob?.cancel()
126+ sendMessageJob?.cancel()
127+ deleteSessionJob?.cancel()
128+ loadMessagesJob?.cancel()
78− scope.launch { loadMessages(event.id) }
129+ loadMessagesJob = scope.launch { loadMessages(event.id) }
132+ Event.CreateSession -> {
133+ if (!connected) { error = "Not connected"; emit(); return }
134+ loading = true; error = null; emit()
135+ createSessionJob?.cancel()
81− Event.CreateSession -> { loading = true; emit(); scope.launch { createSession() } }
136+ createSessionJob = scope.launch { createSession() }
83 is Event.DeleteSession -> {
139 is Event.DeleteSession -> {
140+ if (!connected) { error = "Not connected"; emit(); return }
142+ // Delete on the server first; only drop it from the UI once that
143+ // succeeds, so a failed request leaves the session recoverable.
144+ loading = true; error = null; emit()
145+ deleteSessionJob?.cancel()
146+ deleteSessionJob = scope.launch {
147+ http.delete("$serverUrl/session/$id", authHeader()).fold(
148+ onSuccess = { resp ->
149+ if (resp.code in 200..299) {
84− sessions.removeAll { it.id == event.id }
150+ sessions.removeAll { it.id == id }
85− if (isCurrent(event.id)) {
151+ if (isCurrent(id)) {
86− currentSessionId = null
152+ currentSessionId = null
87− messages.clear()
153+ messages.clear()
88− screen = Screen.Sessions
154+ screen = Screen.Sessions
157+ error = "Failed to delete session"
162+ error = "Failed to delete session"
91− scope.launch { http.delete("$serverUrl/session/${event.id}", authHeader()) }
94 is Event.DraftChanged -> { draft = event.text; emit() }
170 is Event.DraftChanged -> { draft = event.text; emit() }
@@ -96,26 +172,57 @@ class OpencodeCore(
96 is Event.SendMessage -> sendMessage(event.text)
172 is Event.SendMessage -> sendMessage(event.text)
98 Event.CancelGeneration -> {
174 Event.CancelGeneration -> {
99− generating = false; emit()
175+ generating = false
176+ messages.forEach { it.streaming = false }
100 val id = currentSessionId ?: return
178 val id = currentSessionId ?: return
179+ // Debounce rapid cancel taps: cancel any in-flight abort before firing
180+ // another so we don't stack duplicate abort requests.
101− scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
182+ abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
104 Event.NavigateToSessions -> {
185 Event.NavigateToSessions -> {
105 screen = Screen.Sessions
186 screen = Screen.Sessions
106 currentSessionId = null
187 currentSessionId = null
107 messages.clear()
188 messages.clear()
189+ removedMessageIds.clear()
108 generating = false
191 generating = false
192+ // Clear any error from the chat (e.g. a failed send) so it doesn't
193+ // linger on the sessions list after the user navigates back.
197+ connectJob?.cancel()
199+ createSessionJob?.cancel()
200+ deleteSessionJob?.cancel()
201+ sendMessageJob?.cancel()
202+ loadMessagesJob?.cancel()
203+ loadSessionsJob?.cancel()
110− scope.launch { loadSessions() }
204+ loadSessionsJob = scope.launch { loadSessions() }
113 Event.NavigateToConnect -> {
207 Event.NavigateToConnect -> {
114 screen = Screen.Connect
208 screen = Screen.Connect
115 connected = false
209 connected = false
116 sessions.clear()
212 sessions.clear()
117 messages.clear()
213 messages.clear()
214+ removedMessageIds.clear()
118 currentSessionId = null
215 currentSessionId = null
217+ // Reset to the default so a late-firing close callback from the old
218+ // SSE connection can't flash the reconnecting banner on the next connect.
220+ connectJob?.cancel()
222+ createSessionJob?.cancel()
223+ deleteSessionJob?.cancel()
224+ sendMessageJob?.cancel()
225+ loadMessagesJob?.cancel()
119 closeSse()
226 closeSse()
@@ -129,26 +236,44 @@ class OpencodeCore(
129 if (connected && sse == null) startSse()
236 if (connected && sse == null) startSse()
132− fun shutdown() = closeSse()
239+ fun shutdown() {
240+ // Cancel every tracked coroutine before tearing down the stream so none can
241+ // emit into a cancelled scope after the ViewModel is cleared.
242+ connectJob?.cancel()
243+ loadSessionsJob?.cancel()
244+ createSessionJob?.cancel()
245+ loadMessagesJob?.cancel()
246+ deleteSessionJob?.cancel()
247+ sendMessageJob?.cancel()
134 // ── Effects ────────────────────────────────────────────────────────────────
252 // ── Effects ────────────────────────────────────────────────────────────────
135 private fun sendMessage(raw: String) {
253 private fun sendMessage(raw: String) {
136 val text = raw.trim()
254 val text = raw.trim()
137 val session = currentSessionId ?: return
255 val session = currentSessionId ?: return
138 if (text.isEmpty()) return
256 if (text.isEmpty()) return
257+ // Debounce rapid double-taps on send: a send is already in flight, so a second
258+ // tap would create a duplicate user message and fire a duplicate request.
259+ if (generating) return
139 localSeq += 1
260 localSeq += 1
140− val user = MsgState("local-$localSeq", "user", 0).apply {
261+ val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
141 status = MessageStatus.Pending
262 status = MessageStatus.Pending
142 textParts["local"] = text
263 textParts["local"] = text
144 messages.add(user)
265 messages.add(user)
266+ // Keep the draft text so we can restore it if the send fails; otherwise the
267+ // user's message is lost and they'd have to retype it.
268+ val savedDraft = draft
145 draft = ""
269 draft = ""
146 generating = true
270 generating = true
147 error = null
271 error = null
150 val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
274 val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
275+ sendMessageJob?.cancel()
151− scope.launch {
276+ sendMessageJob = scope.launch {
152 val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
277 val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
153 result.fold(
278 result.fold(
154 onSuccess = { resp ->
279 onSuccess = { resp ->
@@ -157,12 +282,16 @@ class OpencodeCore(
158 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
283 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
159 generating = false
284 generating = false
285+ messages.forEach { it.streaming = false }
160 error = "Server returned status ${resp.code}"
287 error = "Server returned status ${resp.code}"
163 onFailure = { e ->
290 onFailure = { e ->
164 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
291 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
165 generating = false
292 generating = false
293+ messages.forEach { it.streaming = false }
166 error = "Request failed: ${e.message}"
295 error = "Request failed: ${e.message}"
@@ -177,11 +306,17 @@ class OpencodeCore(
178 resp.code == 401 -> {
307 resp.code == 401 -> {
179 loading = false
308 loading = false
309+ // A prior connection may have succeeded (connected = true); a
310+ // re-probe returning 401 (e.g. expired credentials) means we're
311+ // no longer authenticated, so drop the connected flag.
313+ // Always reveal the auth fields so the user can see/edit
314+ // their credentials — even when the ones supplied were wrong.
180− if (hasCreds) error = "Invalid credentials" else authRequired = true
316+ if (hasCreds) error = "Invalid credentials"
183 resp.code in 200..299 -> {
319 resp.code in 200..299 -> {
184− if (hasCreds) authed = true
185 connected = true
320 connected = true
186 authRequired = false
321 authRequired = false
187 error = null
322 error = null
@@ -189,10 +324,14 @@ class OpencodeCore(
189 loading = true
324 loading = true
191 startSse()
326 startSse()
327+ // Route through loadSessionsJob so a prior in-flight loadSessions
328+ // (e.g. from a rapid double-connect) is cancelled first.
329+ loadSessionsJob?.cancel()
192− loadSessions()
330+ loadSessionsJob = scope.launch { loadSessions() }
194 else -> {
332 else -> {
195 loading = false
333 loading = false
196 error = "Server returned status ${resp.code}"
335 error = "Server returned status ${resp.code}"
@@ -200,6 +339,7 @@ class OpencodeCore(
201 onFailure = { e ->
340 onFailure = { e ->
202 loading = false
341 loading = false
203 error = "Connection failed: ${e.message}"
343 error = "Connection failed: ${e.message}"
@@ -207,31 +347,38 @@ class OpencodeCore(
209 private suspend fun loadSessions() {
349 private suspend fun loadSessions() {
211 http.get("$serverUrl/session", authHeader()).fold(
350 http.get("$serverUrl/session", authHeader()).fold(
212 onSuccess = { resp ->
351 onSuccess = { resp ->
213 if (resp.code in 200..299) {
352 if (resp.code in 200..299) {
214 sessions.clear()
353 sessions.clear()
215 sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
354 sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
355+ // Impose a stable alphabetical order so the list doesn't shuffle with
356+ // whatever order the server happens to return sessions in.
357+ sessions.sortBy { it.title.lowercase() }
217 error = "Server returned status ${resp.code}"
359 error = "Server returned status ${resp.code}"
220 onFailure = { e -> error = "Request failed: ${e.message}" },
362 onFailure = { e -> error = "Request failed: ${e.message}" },
225 private suspend fun createSession() {
368 private suspend fun createSession() {
227 http.postJson("$serverUrl/session", "{}", authHeader()).fold(
369 http.postJson("$serverUrl/session", "{}", authHeader()).fold(
228 onSuccess = { resp ->
370 onSuccess = { resp ->
229 val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
371 val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
230 if (session != null) {
372 if (session != null) {
231 if (sessions.none { it.id == session.id }) {
373 if (sessions.none { it.id == session.id }) {
232 sessions.add(0, SessionView(session.id, displayTitle(session.title)))
374 sessions.add(0, SessionView(session.id, displayTitle(session.title)))
375+ // Restore the alphabetical order immediately (matching loadSessions /
376+ // SessionUpserted) so the new session doesn't sit out-of-order at the
377+ // top until the SSE SessionUpserted event arrives to re-sort.
378+ sessions.sortBy { it.title.lowercase() }
381+ // SelectSession takes over the loading state (loads messages).
235 dispatch(Event.SelectSession(session.id))
382 dispatch(Event.SelectSession(session.id))
@@ -239,16 +386,18 @@ class OpencodeCore(
240 onFailure = { e -> error = "Request failed: ${e.message}" },
387 onFailure = { e -> error = "Request failed: ${e.message}" },
245 private suspend fun loadMessages(sessionId: String) {
393 private suspend fun loadMessages(sessionId: String) {
246 http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
394 http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
247 onSuccess = { resp ->
395 onSuccess = { resp ->
248− if (!isCurrent(sessionId)) return // user navigated away while loading
396+ if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
249 loading = false
397 loading = false
250 if (resp.code in 200..299) {
398 if (resp.code in 200..299) {
251 messages.clear()
399 messages.clear()
400+ removedMessageIds.clear()
252 messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
401 messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
253 generating = messages.any { it.streaming }
402 generating = messages.any { it.streaming }
@@ -257,8 +406,9 @@ class OpencodeCore(
259 onFailure = { e ->
408 onFailure = { e ->
260− if (!isCurrent(sessionId)) return
409+ if (!isCurrent(sessionId)) { loading = false; return }
261 loading = false
410 loading = false
262 error = "Request failed: ${e.message}"
412 error = "Request failed: ${e.message}"
@@ -273,7 +423,13 @@ class OpencodeCore(
273 url = "$serverUrl/event",
423 url = "$serverUrl/event",
274 auth = authHeader(),
424 auth = authHeader(),
275 onEvent = { json -> scope.launch { handleServerEvent(json) } },
425 onEvent = { json -> scope.launch { handleServerEvent(json) } },
276− onStateChange = { open -> if (!open) scheduleReconnect() },
426+ onStateChange = { open ->
429+ if (open) reconnectAttempts = 0 else scheduleReconnect()
278 sse = client
434 sse = client
279 client.connect()
435 client.connect()
@@ -281,29 +437,43 @@ class OpencodeCore(
282 private fun closeSse() {
438 private fun closeSse() {
283 sseClosedIntentionally = true
439 sseClosedIntentionally = true
440+ reconnectJob?.cancel()
284 sse?.close()
442 sse?.close()
285 sse = null
443 sse = null
288 private fun scheduleReconnect() {
446 private fun scheduleReconnect() {
289 if (sseClosedIntentionally || !connected) return
447 if (sseClosedIntentionally || !connected) return
448+ // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s. Reset to 0 once the
449+ // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
450+ val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
451+ if (delayMs < 30_000L) reconnectAttempts += 1
452+ // Cancel any pending reconnect so rapid network flapping can't stack multiple
453+ // coroutines that each open a new SSE connection.
454+ reconnectJob?.cancel()
290− scope.launch {
455+ reconnectJob = scope.launch {
291− delay(2000)
456+ delay(delayMs)
292 if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
457 if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
296 private fun handleServerEvent(json: String) {
461 private fun handleServerEvent(json: String) {
297 val event = Protocol.parseEvent(json) ?: return
462 val event = Protocol.parseEvent(json) ?: return
298− applyEvent(event)
463+ // Only re-render if the event actually changed our state; applyEvent returns
299− emit()
464+ // false for events that don't apply (other session, unknown message, …).
465+ if (applyEvent(event)) emit()
468+ /** Apply a server event to the model. Returns true iff any state actually changed. */
302− private fun applyEvent(event: Protocol.Event) {
469+ private fun applyEvent(event: Protocol.Event): Boolean {
303 when (event) {
470 when (event) {
304 is Protocol.Event.MessageUpdated -> {
471 is Protocol.Event.MessageUpdated -> {
305 val info = event.info
472 val info = event.info
306− if (info.role != "assistant" || !isCurrent(info.sessionId)) return
473+ if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
474+ // A MessageRemoved may have arrived before this (late/out-of-order) update;
475+ // don't re-create a message the server has already deleted.
476+ if (info.id in removedMessageIds) return false
307 val streaming = info.time.completed == null && info.error == null
477 val streaming = info.time.completed == null && info.error == null
308 val existing = messages.firstOrNull { it.id == info.id }
478 val existing = messages.firstOrNull { it.id == info.id }
309 if (existing != null) {
479 if (existing != null) {
@@ -312,38 +482,59 @@ class OpencodeCore(
313 messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
483 messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
315− info.error?.let { error = it.message(); generating = false }
485+ info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
318 is Protocol.Event.PartUpdated -> {
489 is Protocol.Event.PartUpdated -> {
319 val part = event.part
490 val part = event.part
320− val msg = messages.firstOrNull { it.id == part.messageId } ?: return
491+ val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
492+ // Synthetic text parts are intentionally skipped; report no change so we
493+ // don't trigger a needless re-render/emit for them.
321− when (part) {
494+ return when (part) {
322− is Protocol.Part.Text -> if (!part.synthetic) {
495+ is Protocol.Part.Text -> if (part.synthetic) {
323 msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
498 msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
325− is Protocol.Part.Reasoning ->
501+ is Protocol.Part.Reasoning -> {
326 msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
502 msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
327− is Protocol.Part.Tool ->
505+ is Protocol.Part.Tool -> {
328 msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
506 msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
512+ is Protocol.Event.PartRemoved -> {
332− is Protocol.Event.PartRemoved -> messages.firstOrNull { it.id == event.messageId }?.remove(event.partId)
513+ val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
514+ msg.remove(event.partId)
518+ is Protocol.Event.MessageRemoved -> {
519+ // Remember the ID so a late MessageUpdated can't resurrect the message.
520+ removedMessageIds.add(event.messageId)
334− is Protocol.Event.MessageRemoved -> messages.removeAll { it.id == event.messageId }
521+ return messages.removeAll { it.id == event.messageId }
336− is Protocol.Event.SessionIdle -> if (isCurrent(event.sessionId)) {
524+ is Protocol.Event.SessionIdle -> {
525+ if (!isCurrent(event.sessionId)) return false
337 generating = false
526 generating = false
338 messages.forEach { it.streaming = false }
527 messages.forEach { it.streaming = false }
341 is Protocol.Event.SessionError -> {
531 is Protocol.Event.SessionError -> {
342 val scoped = event.sessionId == null || isCurrent(event.sessionId)
532 val scoped = event.sessionId == null || isCurrent(event.sessionId)
343− if (scoped) {
533+ if (!scoped) return false
344− event.error?.let { error = it.message() }
534+ event.error?.let { error = it.message() }
345− generating = false
535+ generating = false
346− }
536+ messages.forEach { it.streaming = false }
349 is Protocol.Event.SessionUpserted -> {
540 is Protocol.Event.SessionUpserted -> {
@@ -354,19 +545,26 @@ class OpencodeCore(
355 sessions.add(0, SessionView(event.info.id, title))
546 sessions.add(0, SessionView(event.info.id, title))
548+ // Keep the list alphabetically sorted (matching loadSessions) so an
549+ // upserted session doesn't jump to the top and break the ordering.
550+ sessions.sortBy { it.title.lowercase() }
357 if (isCurrent(event.info.id)) currentSessionTitle = title
551 if (isCurrent(event.info.id)) currentSessionTitle = title
360− is Protocol.Event.SessionDeleted -> event.sessionId?.let { id ->
555+ is Protocol.Event.SessionDeleted -> {
556+ val id = event.sessionId ?: return false
361− sessions.removeAll { it.id == id }
557+ val removed = sessions.removeAll { it.id == id }
362 if (isCurrent(id)) {
558 if (isCurrent(id)) {
363 currentSessionId = null
559 currentSessionId = null
364 messages.clear()
560 messages.clear()
365 screen = Screen.Sessions
561 screen = Screen.Sessions
369− Protocol.Event.Other -> Unit
567+ Protocol.Event.Other -> return false
@@ -386,6 +584,7 @@ class OpencodeCore(
386 messages = messages.map { it.toView() },
584 messages = messages.map { it.toView() },
387 draft = draft,
585 draft = draft,
388 generating = generating,
586 generating = generating,
587+ sseConnected = sseConnected,
391 private fun emit() { _view.value = snapshot() }
590 private fun emit() { _view.value = snapshot() }