@@ -1,7 +1,6 @@
1package soy.iko.opencode.core
1package soy.iko.opencode.core
3import kotlinx.coroutines.CoroutineScope
3import kotlinx.coroutines.CoroutineScope
4−import kotlinx.coroutines.Job
5import kotlinx.coroutines.delay
4import kotlinx.coroutines.delay
6import kotlinx.coroutines.flow.MutableStateFlow
5import kotlinx.coroutines.flow.MutableStateFlow
7import kotlinx.coroutines.flow.StateFlow
6import kotlinx.coroutines.flow.StateFlow
@@ -30,6 +29,7 @@ class OpencodeCore(
30 private var username = ""
29 private var username = ""
31 private var password = ""
30 private var password = ""
32 private var authRequired = false
31 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,7 +40,6 @@ 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
44 private var localSeq = 0L
43 private var localSeq = 0L
46 private val _view = MutableStateFlow(snapshot())
45 private val _view = MutableStateFlow(snapshot())
@@ -48,123 +47,44 @@ class OpencodeCore(
49 private var sse: SseClient? = null
48 private var sse: SseClient? = null
50 private var sseClosedIntentionally = false
49 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>()
78 // ── Event entry point ──────────────────────────────────────────────────────
51 // ── Event entry point ──────────────────────────────────────────────────────
79 fun dispatch(event: Event) {
52 fun dispatch(event: Event) {
80 when (event) {
53 when (event) {
81− // Store the field verbatim while typing; normalizing here would fight
82− // the user's edits (e.g. eat the "://" as they type it). The URL is
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.
86− is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
54+ is Event.ServerUrlChanged -> { serverUrl = normalizeUrl(event.url); emit() }
87− is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
55+ is Event.UsernameChanged -> { username = event.value; emit() }
88− is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
56+ is Event.PasswordChanged -> { password = event.value; emit() }
90 Event.Connect -> {
58 Event.Connect -> {
91− serverUrl = normalizeUrl(serverUrl)
92 if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
59 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.
98− loading = true; error = null; emit()
60+ loading = true; error = null; authRequired = false; emit()
100− connectJob = scope.launch { probeHealth() }
61+ scope.launch { probeHealth() }
103− Event.LoadSessions -> {
104− if (!connected) { error = "Not connected"; emit(); return }
105− loading = true; emit()
106− loadSessionsJob?.cancel()
107− loadSessionsJob = scope.launch { loadSessions() }
64+ Event.LoadSessions -> { loading = true; emit(); scope.launch { loadSessions() } }
110 is Event.SelectSession -> {
66 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.
113− val session = sessions.firstOrNull { it.id == event.id }
67+ currentSessionTitle = sessions.firstOrNull { it.id == event.id }?.title.orEmpty()
114− if (session == null) { error = "Session no longer exists"; emit(); return }
115− currentSessionTitle = session.title
116 currentSessionId = event.id
68 currentSessionId = event.id
117 messages.clear()
69 messages.clear()
118− removedMessageIds.clear()
119 screen = Screen.Chat
70 screen = Screen.Chat
120 loading = true
71 loading = true
122 error = null
72 error = null
124− connectJob?.cancel()
126− sendMessageJob?.cancel()
127− deleteSessionJob?.cancel()
128− loadMessagesJob?.cancel()
129− loadMessagesJob = scope.launch { loadMessages(event.id) }
74+ 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()
136− createSessionJob = scope.launch { createSession() }
77+ Event.CreateSession -> { loading = true; emit(); scope.launch { createSession() } }
139 is Event.DeleteSession -> {
79 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) {
150− sessions.removeAll { it.id == id }
80+ sessions.removeAll { it.id == event.id }
151− if (isCurrent(id)) {
81+ if (isCurrent(event.id)) {
152− currentSessionId = null
82+ currentSessionId = null
153− messages.clear()
83+ messages.clear()
154− screen = Screen.Sessions
84+ screen = Screen.Sessions
157− error = "Failed to delete session"
162− error = "Failed to delete session"
87+ scope.launch { http.delete("$serverUrl/session/${event.id}", authHeader()) }
170 is Event.DraftChanged -> { draft = event.text; emit() }
90 is Event.DraftChanged -> { draft = event.text; emit() }
@@ -172,57 +92,26 @@ class OpencodeCore(
172 is Event.SendMessage -> sendMessage(event.text)
92 is Event.SendMessage -> sendMessage(event.text)
174 Event.CancelGeneration -> {
94 Event.CancelGeneration -> {
175− generating = false
95+ generating = false; emit()
176− messages.forEach { it.streaming = false }
178 val id = currentSessionId ?: return
96 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.
182− abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
97+ scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
185 Event.NavigateToSessions -> {
100 Event.NavigateToSessions -> {
186 screen = Screen.Sessions
101 screen = Screen.Sessions
187 currentSessionId = null
102 currentSessionId = null
188 messages.clear()
103 messages.clear()
189− removedMessageIds.clear()
191 generating = false
104 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()
204− loadSessionsJob = scope.launch { loadSessions() }
106+ scope.launch { loadSessions() }
207 Event.NavigateToConnect -> {
109 Event.NavigateToConnect -> {
208 screen = Screen.Connect
110 screen = Screen.Connect
209 connected = false
111 connected = false
212 sessions.clear()
112 sessions.clear()
213 messages.clear()
113 messages.clear()
214− removedMessageIds.clear()
215 currentSessionId = null
114 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()
226 closeSse()
115 closeSse()
@@ -236,44 +125,26 @@ class OpencodeCore(
236 if (connected && sse == null) startSse()
125 if (connected && sse == null) startSse()
239− fun shutdown() {
128+ fun shutdown() = closeSse()
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()
252 // ── Effects ────────────────────────────────────────────────────────────────
130 // ── Effects ────────────────────────────────────────────────────────────────
253 private fun sendMessage(raw: String) {
131 private fun sendMessage(raw: String) {
254 val text = raw.trim()
132 val text = raw.trim()
255 val session = currentSessionId ?: return
133 val session = currentSessionId ?: return
256 if (text.isEmpty()) return
134 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
260 localSeq += 1
135 localSeq += 1
261− val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
136+ val user = MsgState("local-$localSeq", "user", 0).apply {
262 status = MessageStatus.Pending
137 status = MessageStatus.Pending
263 textParts["local"] = text
138 textParts["local"] = text
265 messages.add(user)
140 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
269 draft = ""
141 draft = ""
270 generating = true
142 generating = true
271 error = null
143 error = null
274 val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
146 val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
275− sendMessageJob?.cancel()
276− sendMessageJob = scope.launch {
147+ scope.launch {
277 val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
148 val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
278 result.fold(
149 result.fold(
279 onSuccess = { resp ->
150 onSuccess = { resp ->
@@ -282,16 +153,12 @@ class OpencodeCore(
283 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
154 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
284 generating = false
155 generating = false
285− messages.forEach { it.streaming = false }
287 error = "Server returned status ${resp.code}"
156 error = "Server returned status ${resp.code}"
290 onFailure = { e ->
159 onFailure = { e ->
291 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
160 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
292 generating = false
161 generating = false
293− messages.forEach { it.streaming = false }
295 error = "Request failed: ${e.message}"
162 error = "Request failed: ${e.message}"
@@ -306,17 +173,11 @@ class OpencodeCore(
307 resp.code == 401 -> {
174 resp.code == 401 -> {
308 loading = false
175 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.
316− if (hasCreds) error = "Invalid credentials"
176+ if (hasCreds) error = "Invalid credentials" else authRequired = true
319 resp.code in 200..299 -> {
179 resp.code in 200..299 -> {
180+ if (hasCreds) authed = true
320 connected = true
181 connected = true
321 authRequired = false
182 authRequired = false
322 error = null
183 error = null
@@ -324,14 +185,10 @@ class OpencodeCore(
324 loading = true
185 loading = true
326 startSse()
187 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()
330− loadSessionsJob = scope.launch { loadSessions() }
188+ loadSessions()
332 else -> {
190 else -> {
333 loading = false
191 loading = false
335 error = "Server returned status ${resp.code}"
192 error = "Server returned status ${resp.code}"
@@ -339,7 +196,6 @@ class OpencodeCore(
340 onFailure = { e ->
197 onFailure = { e ->
341 loading = false
198 loading = false
343 error = "Connection failed: ${e.message}"
199 error = "Connection failed: ${e.message}"
@@ -347,38 +203,31 @@ class OpencodeCore(
349 private suspend fun loadSessions() {
205 private suspend fun loadSessions() {
350 http.get("$serverUrl/session", authHeader()).fold(
207 http.get("$serverUrl/session", authHeader()).fold(
351 onSuccess = { resp ->
208 onSuccess = { resp ->
352 if (resp.code in 200..299) {
209 if (resp.code in 200..299) {
353 sessions.clear()
210 sessions.clear()
354 sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
211 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() }
359 error = "Server returned status ${resp.code}"
213 error = "Server returned status ${resp.code}"
362 onFailure = { e -> error = "Request failed: ${e.message}" },
216 onFailure = { e -> error = "Request failed: ${e.message}" },
368 private suspend fun createSession() {
221 private suspend fun createSession() {
369 http.postJson("$serverUrl/session", "{}", authHeader()).fold(
223 http.postJson("$serverUrl/session", "{}", authHeader()).fold(
370 onSuccess = { resp ->
224 onSuccess = { resp ->
371 val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
225 val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
372 if (session != null) {
226 if (session != null) {
373 if (sessions.none { it.id == session.id }) {
227 if (sessions.none { it.id == session.id }) {
374 sessions.add(0, SessionView(session.id, displayTitle(session.title)))
228 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).
382 dispatch(Event.SelectSession(session.id))
231 dispatch(Event.SelectSession(session.id))
@@ -386,18 +235,16 @@ class OpencodeCore(
387 onFailure = { e -> error = "Request failed: ${e.message}" },
236 onFailure = { e -> error = "Request failed: ${e.message}" },
393 private suspend fun loadMessages(sessionId: String) {
241 private suspend fun loadMessages(sessionId: String) {
394 http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
242 http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
395 onSuccess = { resp ->
243 onSuccess = { resp ->
396− if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
244+ if (!isCurrent(sessionId)) return // user navigated away while loading
397 loading = false
245 loading = false
398 if (resp.code in 200..299) {
246 if (resp.code in 200..299) {
399 messages.clear()
247 messages.clear()
400− removedMessageIds.clear()
401 messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
248 messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
402 generating = messages.any { it.streaming }
249 generating = messages.any { it.streaming }
@@ -406,9 +253,8 @@ class OpencodeCore(
408 onFailure = { e ->
255 onFailure = { e ->
409− if (!isCurrent(sessionId)) { loading = false; return }
256+ if (!isCurrent(sessionId)) return
410 loading = false
257 loading = false
412 error = "Request failed: ${e.message}"
258 error = "Request failed: ${e.message}"
@@ -423,13 +269,7 @@ class OpencodeCore(
423 url = "$serverUrl/event",
269 url = "$serverUrl/event",
424 auth = authHeader(),
270 auth = authHeader(),
425 onEvent = { json -> scope.launch { handleServerEvent(json) } },
271 onEvent = { json -> scope.launch { handleServerEvent(json) } },
426− onStateChange = { open ->
272+ onStateChange = { open -> if (!open) scheduleReconnect() },
429− if (open) reconnectAttempts = 0 else scheduleReconnect()
434 sse = client
274 sse = client
435 client.connect()
275 client.connect()
@@ -437,43 +277,29 @@ class OpencodeCore(
438 private fun closeSse() {
278 private fun closeSse() {
439 sseClosedIntentionally = true
279 sseClosedIntentionally = true
440− reconnectJob?.cancel()
442 sse?.close()
280 sse?.close()
443 sse = null
281 sse = null
446 private fun scheduleReconnect() {
284 private fun scheduleReconnect() {
447 if (sseClosedIntentionally || !connected) return
285 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()
455− reconnectJob = scope.launch {
286+ scope.launch {
456− delay(delayMs)
287+ delay(2000)
457 if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
288 if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
461 private fun handleServerEvent(json: String) {
292 private fun handleServerEvent(json: String) {
462 val event = Protocol.parseEvent(json) ?: return
293 val event = Protocol.parseEvent(json) ?: return
463− // Only re-render if the event actually changed our state; applyEvent returns
294+ applyEvent(event)
464− // false for events that don't apply (other session, unknown message, …).
295+ emit()
465− if (applyEvent(event)) emit()
468− /** Apply a server event to the model. Returns true iff any state actually changed. */
469− private fun applyEvent(event: Protocol.Event): Boolean {
298+ private fun applyEvent(event: Protocol.Event) {
470 when (event) {
299 when (event) {
471 is Protocol.Event.MessageUpdated -> {
300 is Protocol.Event.MessageUpdated -> {
472 val info = event.info
301 val info = event.info
473− if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
302+ if (info.role != "assistant" || !isCurrent(info.sessionId)) return
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
477 val streaming = info.time.completed == null && info.error == null
303 val streaming = info.time.completed == null && info.error == null
478 val existing = messages.firstOrNull { it.id == info.id }
304 val existing = messages.firstOrNull { it.id == info.id }
479 if (existing != null) {
305 if (existing != null) {
@@ -482,59 +308,38 @@ class OpencodeCore(
483 messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
309 messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
485− info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
311+ info.error?.let { error = it.message(); generating = false }
489 is Protocol.Event.PartUpdated -> {
314 is Protocol.Event.PartUpdated -> {
490 val part = event.part
315 val part = event.part
491− val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
316+ val msg = messages.firstOrNull { it.id == part.messageId } ?: return
492− // Synthetic text parts are intentionally skipped; report no change so we
493− // don't trigger a needless re-render/emit for them.
494− return when (part) {
317+ when (part) {
495− is Protocol.Part.Text -> if (part.synthetic) {
318+ is Protocol.Part.Text -> if (!part.synthetic) {
498 msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
319 msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
501− is Protocol.Part.Reasoning -> {
321+ is Protocol.Part.Reasoning ->
502 msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
322 msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
505− is Protocol.Part.Tool -> {
323+ is Protocol.Part.Tool ->
506 msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
324 msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
512− is Protocol.Event.PartRemoved -> {
513− val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
328+ is Protocol.Event.PartRemoved -> messages.firstOrNull { it.id == event.messageId }?.remove(event.partId)
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)
521− return messages.removeAll { it.id == event.messageId }
330+ is Protocol.Event.MessageRemoved -> messages.removeAll { it.id == event.messageId }
524− is Protocol.Event.SessionIdle -> {
332+ is Protocol.Event.SessionIdle -> if (isCurrent(event.sessionId)) {
525− if (!isCurrent(event.sessionId)) return false
526 generating = false
333 generating = false
527 messages.forEach { it.streaming = false }
334 messages.forEach { it.streaming = false }
531 is Protocol.Event.SessionError -> {
337 is Protocol.Event.SessionError -> {
532 val scoped = event.sessionId == null || isCurrent(event.sessionId)
338 val scoped = event.sessionId == null || isCurrent(event.sessionId)
533− if (!scoped) return false
339+ if (scoped) {
534− event.error?.let { error = it.message() }
340+ event.error?.let { error = it.message() }
535− generating = false
341+ generating = false
536− messages.forEach { it.streaming = false }
342+ }
540 is Protocol.Event.SessionUpserted -> {
345 is Protocol.Event.SessionUpserted -> {
@@ -545,26 +350,19 @@ class OpencodeCore(
546 sessions.add(0, SessionView(event.info.id, title))
351 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() }
551 if (isCurrent(event.info.id)) currentSessionTitle = title
353 if (isCurrent(event.info.id)) currentSessionTitle = title
555− is Protocol.Event.SessionDeleted -> {
356+ is Protocol.Event.SessionDeleted -> event.sessionId?.let { id ->
556− val id = event.sessionId ?: return false
557− val removed = sessions.removeAll { it.id == id }
357+ sessions.removeAll { it.id == id }
558 if (isCurrent(id)) {
358 if (isCurrent(id)) {
559 currentSessionId = null
359 currentSessionId = null
560 messages.clear()
360 messages.clear()
561 screen = Screen.Sessions
361 screen = Screen.Sessions
567− Protocol.Event.Other -> return false
365+ Protocol.Event.Other -> Unit
@@ -584,7 +382,6 @@ class OpencodeCore(
584 messages = messages.map { it.toView() },
382 messages = messages.map { it.toView() },
585 draft = draft,
383 draft = draft,
586 generating = generating,
384 generating = generating,
587− sseConnected = sseConnected,
590 private fun emit() { _view.value = snapshot() }
387 private fun emit() { _view.value = snapshot() }
@@ -647,16 +444,7 @@ private fun messageFromWire(env: Protocol.WireMessageEnvelope): MsgState {
647private fun pickText(text: String, delta: String?, prior: String?): String =
444private fun pickText(text: String, delta: String?, prior: String?): String =
648 if (text.isNotEmpty()) text else (prior.orEmpty() + (delta ?: ""))
445 if (text.isNotEmpty()) text else (prior.orEmpty() + (delta ?: ""))
650−private fun normalizeUrl(url: String): String {
447+private fun normalizeUrl(url: String): String = url.trim().trimEnd('/')
651− val trimmed = url.trim().trimEnd('/')
652− // Default to http:// when the user didn't type a scheme (e.g. "192.168.1.10:4096").
653− // Without a scheme OkHttp's URL parser throws and the connect attempt crashes.
655− trimmed.isEmpty() -> ""
656− "://" in trimmed -> trimmed
657− else -> "http://$trimmed"
661private fun displayTitle(title: String): String = title.trim().ifEmpty { "Untitled" }
449private fun displayTitle(title: String): String = title.trim().ifEmpty { "Untitled" }