git.iko.soy/opencode-android
on master help sign in
Re-seed messages on SSE reconnect, fix _running send race, and guard uncaught UI coroutine launches
commitc95dd6c808a0c74d58980c36af1b53d3a653b032 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 05:30 (3 months ago) parentcea6f43
6 files changed+28−10
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+1−1 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+1−1 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+20 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+2−4 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+2−2 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+2−2
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+1−1
@@ -53,7 +53,7 @@ class CrashLogger private constructor(private val appContext: Context) {
5353 // Load crash reports off the main thread so startup isn't blocked by file I/O
5454 // (listFiles + reading the first line of each report). The StateFlow updates
5555 // whenever the scan completes, so the Diagnostics screen reflects the result.
56− scope.launch { refresh() }
56+ scope.launch { runCatching { refresh() } }
5757 }
5858
5959 fun refresh() {
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+1−1
@@ -27,7 +27,7 @@ class DraftStore(context: Context, scope: CoroutineScope) {
2727 // get() doesn't block on disk I/O. The latch ensures get() waits for this
2828 // background load instead of triggering its own lazy load on the main thread.
2929 scope.launch {
30− prefs.all
30+ runCatching { prefs.all }
3131 prefsReady.countDown()
3232 }
3333 }
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+20
@@ -71,6 +71,26 @@ class SessionRepository(
7171 }
7272 }
7373
74+ // Re-seed from REST when the SSE stream reconnects after a drop. Events emitted
75+ // by the server during the disconnection gap are lost from the in-memory state;
76+ // re-fetching the current snapshot fills those holes. The seed merge logic
77+ // (see MessageStore.seed) handles interleaving with concurrently-arrived events.
78+ launch {
79+ var wasConnected = false
80+ eventStream.state.collect { state ->
81+ if (state == EventStreamClient.ConnectionState.Connected) {
82+ if (wasConnected) {
83+ val fresh = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
84+ lock.withLock { store.seed(fresh) }
85+ publish()
86+ }
87+ wasConnected = true
88+ } else if (state == EventStreamClient.ConnectionState.Disconnected) {
89+ wasConnected = false
90+ }
91+ }
92+ }
93+
7494 val initial = runCatching { api.listMessages(sessionId) }
7595 .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE", it) }
7696 .getOrDefault(emptyList())
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+2−4
@@ -215,8 +215,7 @@ class ChatViewModel(
215215 fun send(text: String): Boolean {
216216 val conn = connection ?: return false
217217 val trimmed = text.trim()
218− if (trimmed.isEmpty() || _running.value) return false
219− _running.value = true
218+ if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
220219 _error.value = null
221220 _failedDraft.value = null
222221 // Clear the in-memory draft for the UI immediately, but don't persist the clear
@@ -255,8 +254,7 @@ class ChatViewModel(
255254 /** Invoke a slash-command by name via the server's /command endpoint. */
256255 fun runCommand(command: Command) {
257256 val conn = connection ?: return
258− if (_running.value) return
259− _running.value = true
257+ if (!_running.compareAndSet(false, true)) return
260258 _error.value = null
261259 viewModelScope.launch {
262260 runCatching {
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+2−2
@@ -73,7 +73,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
7373 actions = {
7474 if (reports.isNotEmpty()) {
7575 IconButton(onClick = {
76− shareScope.launch(Dispatchers.IO) { logger.clearAll() }
76+ shareScope.launch(Dispatchers.IO) { runCatching { logger.clearAll() } }
7777 }) {
7878 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
7979 }
@@ -176,7 +176,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
176176 }) { Text(stringResource(R.string.share)) }
177177 Spacer(Modifier.size(8.dp))
178178 TextButton(onClick = {
179− shareScope.launch(Dispatchers.IO) { logger.deleteReport(reportName) }
179+ shareScope.launch(Dispatchers.IO) { runCatching { logger.deleteReport(reportName) } }
180180 viewing = null
181181 }) { Text(stringResource(R.string.delete)) }
182182 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+2−2
@@ -93,7 +93,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
9393 ThemeRow(
9494 mode = mode,
9595 selected = themeMode == mode,
96− onSelect = { scope.launch { container.settingsStore.setThemeMode(mode) } },
96+ onSelect = { scope.launch { runCatching { container.settingsStore.setThemeMode(mode) } } },
9797 )
9898 }
9999
@@ -112,7 +112,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
112112 }
113113 Switch(
114114 checked = dynamicColor,
115− onCheckedChange = { scope.launch { container.settingsStore.setDynamicColor(it) } },
115+ onCheckedChange = { scope.launch { runCatching { container.settingsStore.setDynamicColor(it) } } },
116116 )
117117 }
118118