git.iko.soy/opencode-android
on master help sign in
Fix SSE idle-stream timeout, disconnect/connect race, CancellationException swallowing, seed pruning, auth retry loop, and several UI races and crashes
commit8115408ad7398636cec5753df8f52e01379a2682 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 05:46 (3 months ago) parentc95dd6c
14 files changed+189−85
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+50−35 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+2−2 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+12−2 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+8−9 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+5−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+13−11 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+3 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+6−3 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+11−4 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+14−10 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+7−6 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+12−1 app/src/main/java/soy/iko/opencode/util/Coroutines.kt+17 app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt+29
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+50−35
@@ -67,6 +67,41 @@ class EventStreamClient(
6767 /** Request an immediate reconnect, skipping any in-progress backoff. */
6868 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
6969
70+ /**
71+ * Read SSE events from [events], racing each read against an idle-timeout watchdog.
72+ * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
73+ * clean stream close or scope cancellation.
74+ */
75+ private suspend fun readSseEvents(
76+ scope: CoroutineScope,
77+ events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
78+ send: suspend (BusEvent) -> Unit,
79+ ) {
80+ while (scope.isActive) {
81+ // The OkHttp engine has readTimeout=0 (the SSE stream must never time out on
82+ // read), so a socket the peer never closed would otherwise hang until OS TCP
83+ // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
84+ // within the idle timeout, treat the connection as half-open and drop it.
85+ val result: ChannelResult<ServerSentEvent>? = select {
86+ events.onReceiveCatching { it }
87+ onTimeout(NetworkConfig.sseIdleTimeoutMs) { null }
88+ }
89+ if (result == null) throw IOException("SSE idle timeout, reconnecting")
90+ val sse = result.getOrNull() ?: break
91+ val data = sse.data ?: continue
92+ val event = try {
93+ OpencodeJson.decodeFromString(BusEvent.serializer(), data)
94+ } catch (e: kotlinx.serialization.SerializationException) {
95+ Log.d("EventStream", "Skipping unparseable SSE event", e)
96+ continue
97+ }
98+ if (_state.value != ConnectionState.Connected) {
99+ _state.value = ConnectionState.Connected
100+ }
101+ send(event)
102+ }
103+ }
104+
70105 private fun stream(): Flow<BusEvent> = channelFlow {
71106 val scope = this
72107 // Drain any stale reconnect signal from a previous collection cycle so it
@@ -86,10 +121,13 @@ class EventStreamClient(
86121 request = {
87122 timeout {
88123 requestTimeoutMillis = Long.MAX_VALUE
89− // Use a finite socket timeout so a server that accepts TCP but
90− // never sends SSE headers doesn't hang indefinitely. Once events
91− // are flowing, the idle-timeout watchdog handles half-open detection.
92− socketTimeoutMillis = NetworkConfig.sseHandshakeTimeoutMs
124+ // Disable the socket (read) timeout entirely for the SSE stream.
125+ // A finite value would kill the connection whenever the server
126+ // is quiet for longer than that (idle sessions, slow agent runs),
127+ // causing a reconnect storm. Half-open detection is handled by
128+ // the idle-timeout watchdog below plus OkHttp's pingInterval on
129+ // HTTP/2 connections.
130+ socketTimeoutMillis = Long.MAX_VALUE
93131 }
94132 },
95133 ) {
@@ -102,35 +140,7 @@ class EventStreamClient(
102140 // element can be raced against an idle timeout via select.
103141 val events = incoming.produceIn(scope)
104142 try {
105− while (isActive) {
106− // The OkHttp engine has readTimeout=0 (the SSE stream must
107− // never time out on read), so a socket the peer never closed
108− // would otherwise hang until OS TCP keepalive kicks in —
109− // minutes to hours on stock Linux. If nothing arrives within
110− // the idle timeout, treat the connection as half-open and
111− // drop it so the outer loop reconnects.
112− val result: ChannelResult<ServerSentEvent>? = select {
113− events.onReceiveCatching { it }
114− onTimeout(NetworkConfig.sseIdleTimeoutMs) { null }
115− }
116− // Timeout: drop and reconnect.
117− if (result == null) throw IOException("SSE idle timeout, reconnecting")
118− // Stream closed cleanly: exit to reconnect without a warning log.
119− val sse = result.getOrNull() ?: break
120− val data = sse.data ?: continue
121− val event = try {
122− OpencodeJson.decodeFromString(BusEvent.serializer(), data)
123− } catch (e: kotlinx.serialization.SerializationException) {
124− Log.d("EventStream", "Skipping unparseable SSE event", e)
125− continue
126− }
127− // Any successfully decoded event means the stream is live;
128− // don't wait for a ServerConnected event to show "Connected".
129− if (_state.value != ConnectionState.Connected) {
130− _state.value = ConnectionState.Connected
131− }
132− send(event)
133− }
143+ readSseEvents(scope, events) { send(it) }
134144 } finally {
135145 events.cancel()
136146 }
@@ -138,9 +148,14 @@ class EventStreamClient(
138148 } catch (c: CancellationException) {
139149 throw c
140150 } catch (e: Exception) {
151+ // If the scope was cancelled (e.g. connection close), the closed-client
152+ // exception is expected — don't log a spurious "stream error" warning.
141153 if (isSseAuthFailure(e)) {
142− Log.w("EventStream", "SSE auth failed, will retry with backoff", e)
143− } else {
154+ // Credentials are wrong — retrying won't help. Log and stop.
155+ Log.w("EventStream", "SSE auth failed (401/403), stopping retries", e)
156+ _state.value = ConnectionState.Disconnected
157+ break
158+ } else if (isActive) {
144159 Log.w("EventStream", "SSE stream error, will retry", e)
145160 }
146161 }
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+2−2
@@ -113,7 +113,7 @@ class ProfileStore(context: Context) {
113113 } else {
114114 remove(passwordKey(profile.id))
115115 }
116− }.apply()
116+ }.commit()
117117 }
118118 }
119119
@@ -129,7 +129,7 @@ class ProfileStore(context: Context) {
129129 )
130130 }
131131 withContext(Dispatchers.IO) {
132− securePrefs.edit().remove(passwordKey(id)).apply()
132+ securePrefs.edit().remove(passwordKey(id)).commit()
133133 }
134134 }
135135
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+12−2
@@ -81,7 +81,7 @@ class SessionRepository(
8181 if (state == EventStreamClient.ConnectionState.Connected) {
8282 if (wasConnected) {
8383 val fresh = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
84− lock.withLock { store.seed(fresh) }
84+ lock.withLock { store.seed(fresh, prune = true) }
8585 publish()
8686 }
8787 wasConnected = true
@@ -121,7 +121,17 @@ internal class MessageStore {
121121
122122 fun snapshot(): List<MessageWithParts> = messages.values.toList()
123123
124− fun seed(initial: List<MessageWithParts>) {
124+ fun seed(initial: List<MessageWithParts>, prune: Boolean = false) {
125+ // On re-seed (after SSE reconnect), remove messages that are no longer in the
126+ // server snapshot (e.g. deleted during the disconnection gap). This keeps the
127+ // in-memory state in sync with the authoritative REST snapshot rather than
128+ // accumulating stale messages forever. Skipped on the initial seed to avoid
129+ // racing with just-arrived SSE events whose messages may not yet be in REST.
130+ if (prune) {
131+ val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
132+ messages.keys.retainAll(incomingIds)
133+ }
134+
125135 for (m in initial) {
126136 val existing = messages[m.info.id]
127137 if (existing == null) {
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+8−9
@@ -260,15 +260,14 @@ class AppContainer(context: Context) {
260260 OpencodeConnection(finalProfile).also { _activeConnection.value = it }
261261 }
262262
263− fun disconnect() {
264− // Launch on appScope so disconnect acquires the connection mutex, preventing
265− // a race where a concurrent connect() sets a new connection after disconnect
266− // reads null — which would leak the just-created connection.
267− appScope.launch {
268− connectionMutex.withLock {
269− _activeConnection.value?.close()
270− _activeConnection.value = null
271− }
263+ suspend fun disconnect() {
264+ // Acquire the connection mutex so disconnect is serialized with connect().
265+ // Previously this was fire-and-forget (launch on appScope), which raced with
266+ // a subsequent connect(): connect() could acquire the mutex, create a new
267+ // connection, and then the pending disconnect() would close it.
268+ connectionMutex.withLock {
269+ _activeConnection.value?.close()
270+ _activeConnection.value = null
272271 }
273272 }
274273}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+5−2
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints
44import androidx.compose.runtime.Composable
55import androidx.compose.runtime.LaunchedEffect
66import androidx.compose.runtime.getValue
7+import androidx.compose.runtime.rememberCoroutineScope
78import androidx.compose.ui.unit.dp
89import androidx.navigation.NavType
910import androidx.navigation.compose.NavHost
@@ -11,6 +12,7 @@ import androidx.navigation.compose.composable
1112import androidx.navigation.compose.rememberNavController
1213import androidx.navigation.navArgument
1314import androidx.lifecycle.compose.collectAsStateWithLifecycle
15+import kotlinx.coroutines.launch
1416import soy.iko.opencode.di.AppContainer
1517import soy.iko.opencode.ui.chat.ChatScreen
1618import soy.iko.opencode.ui.file.FileBrowserScreen
@@ -25,6 +27,7 @@ import soy.iko.opencode.ui.settings.SettingsScreen
2527@Composable
2628fun OpencodeApp(container: AppContainer) {
2729 val navController = rememberNavController()
30+ val scope = rememberCoroutineScope()
2831 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
2932 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
3033 val connection by container.activeConnection.collectAsStateWithLifecycle()
@@ -83,7 +86,7 @@ fun OpencodeApp(container: AppContainer) {
8386 onOpenFiles = { navController.navigate(Routes.FILES) },
8487 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
8588 onDisconnect = {
86− container.disconnect()
89+ scope.launch { container.disconnect() }
8790 navController.popBackStack(Routes.SERVERS, inclusive = false)
8891 },
8992 )
@@ -95,7 +98,7 @@ fun OpencodeApp(container: AppContainer) {
9598 navController.navigate(Routes.chat(id))
9699 },
97100 onDisconnect = {
98− container.disconnect()
101+ scope.launch { container.disconnect() }
99102 navController.popBackStack(Routes.SERVERS, inclusive = false)
100103 },
101104 onOpenFiles = { navController.navigate(Routes.FILES) },
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+13−11
@@ -19,6 +19,7 @@ import soy.iko.opencode.data.network.EventStreamClient
1919import soy.iko.opencode.data.repo.SessionRepository
2020import soy.iko.opencode.di.AppContainer
2121import soy.iko.opencode.R
22+import soy.iko.opencode.util.runCatchingCancellable
2223import android.util.Log
2324import kotlinx.coroutines.ExperimentalCoroutinesApi
2425import kotlinx.coroutines.flow.MutableStateFlow
@@ -155,7 +156,7 @@ class ChatViewModel(
155156 container.activeConnection.collectLatest { conn ->
156157 if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; return@collectLatest }
157158 _modelsLoading.value = true
158− runCatching { conn.api.providers() }
159+ runCatchingCancellable { conn.api.providers() }
159160 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
160161 .getOrNull()?.let { resp ->
161162 val options = resp.toOptions()
@@ -170,7 +171,7 @@ class ChatViewModel(
170171 container.activeConnection.collectLatest { conn ->
171172 if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; return@collectLatest }
172173 _agentsLoading.value = true
173− runCatching { conn.api.agents() }
174+ runCatchingCancellable { conn.api.agents() }
174175 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
175176 .getOrNull()?.let { _agents.value = it }
176177 _agentsLoading.value = false
@@ -181,7 +182,7 @@ class ChatViewModel(
181182 container.activeConnection.collectLatest { conn ->
182183 if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; return@collectLatest }
183184 _commandsLoading.value = true
184− runCatching { conn.api.commands() }
185+ runCatchingCancellable { conn.api.commands() }
185186 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
186187 .getOrNull()?.let { _commands.value = it }
187188 _commandsLoading.value = false
@@ -191,7 +192,7 @@ class ChatViewModel(
191192 viewModelScope.launch {
192193 container.activeConnection.collectLatest { conn ->
193194 if (conn == null) return@collectLatest
194− runCatching { conn.repository.listSessions() }
195+ runCatchingCancellable { conn.repository.listSessions() }
195196 .getOrNull()
196197 ?.firstOrNull { it.id == sessionId }
197198 ?.let { _sessionTitle.value = it.displayTitle }
@@ -223,7 +224,7 @@ class ChatViewModel(
223224 // would be lost forever. The persisted draft is cleared only on success.
224225 _draft.value = ""
225226 viewModelScope.launch {
226− runCatching {
227+ runCatchingCancellable {
227228 conn.repository.sendPrompt(
228229 sessionId,
229230 trimmed,
@@ -257,7 +258,7 @@ class ChatViewModel(
257258 if (!_running.compareAndSet(false, true)) return
258259 _error.value = null
259260 viewModelScope.launch {
260− runCatching {
261+ runCatchingCancellable {
261262 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
262263 }.onFailure { _error.value = container.friendlyError(it) }
263264 _running.value = false
@@ -267,9 +268,9 @@ class ChatViewModel(
267268 fun abort() {
268269 val conn = connection ?: return
269270 viewModelScope.launch {
270− runCatching { conn.repository.abort(sessionId) }
271+ runCatchingCancellable { conn.repository.abort(sessionId) }
272+ .onSuccess { _running.value = false }
271273 .onFailure { _error.value = container.friendlyError(it) }
272− _running.value = false
273274 }
274275 }
275276
@@ -281,7 +282,7 @@ class ChatViewModel(
281282 val recent = runCatching { container.profileStore.profiles.first() }
282283 .getOrDefault(emptyList())
283284 .firstOrNull() ?: run { _loading.value = false; return@launch }
284− runCatching {
285+ runCatchingCancellable {
285286 val conn = container.connect(recent)
286287 conn.api.ping()
287288 }.onSuccess {
@@ -301,10 +302,11 @@ class ChatViewModel(
301302 // with a dismissed dialog and a paused tool run.
302303 _pendingPermission.value = null
303304 viewModelScope.launch {
304− runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
305+ runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
305306 .onFailure {
306307 _error.value = container.friendlyError(it)
307− _pendingPermission.value = permission
308+ // Only restore if no new permission has arrived in the meantime.
309+ if (_pendingPermission.value == null) _pendingPermission.value = permission
308310 }
309311 }
310312 }
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+3
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
55import androidx.compose.foundation.layout.Row
66import androidx.compose.foundation.layout.fillMaxWidth
77import androidx.compose.foundation.layout.padding
8+import androidx.activity.compose.BackHandler
89import androidx.compose.material.icons.Icons
910import androidx.compose.material.icons.filled.Shield
1011import androidx.compose.material3.AlertDialog
@@ -35,6 +36,8 @@ fun PermissionDialog(
3536 onRespond: (PermissionResponse) -> Unit,
3637) {
3738 val haptics = LocalHapticFeedback.current
39+ // Intercept back so the dialog can't be dismissed without an explicit choice.
40+ BackHandler { onRespond(PermissionResponse.REJECT) }
3841 AlertDialog(
3942 onDismissRequest = { /* require an explicit choice */ },
4043 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+6−3
@@ -69,9 +69,12 @@ private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
6969 // Resolve relative URL against the base. Collapse any ../ segments to
7070 // prevent path traversal from escaping the server's base path.
7171 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
72− val resolved = java.net.URI(base).resolve(url).normalize().toString()
73− // Guard: if normalization escaped the base path, fall back to the base.
74− if (!resolved.startsWith(base)) base else resolved
72+ val resolved = runCatching {
73+ java.net.URI(base).resolve(url).normalize().toString()
74+ }.getOrElse { return null }
75+ // Guard: if normalization escaped the base path, return null (don't
76+ // attempt to load the base URL itself as an image — it's not one).
77+ if (!resolved.startsWith(base)) null else resolved
7578 }
7679}
7780
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+11−4
@@ -7,6 +7,7 @@ import soy.iko.opencode.data.model.FileStatusEntry
77import soy.iko.opencode.data.network.NetworkConfig
88import soy.iko.opencode.di.AppContainer
99import soy.iko.opencode.R
10+import soy.iko.opencode.util.runCatchingCancellable
1011import kotlinx.coroutines.ExperimentalCoroutinesApi
1112import kotlinx.coroutines.Job
1213import kotlinx.coroutines.delay
@@ -63,11 +64,15 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
6364 }
6465 }
6566
67+ /** Tracks the in-flight VCS status load so a server switch can cancel it. */
68+ private var statusJob: Job? = null
69+
6670 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
6771 private fun loadStatus() {
6872 val client = api ?: return
69− viewModelScope.launch {
70− runCatching { client.fileStatus() }
73+ statusJob?.cancel()
74+ statusJob = viewModelScope.launch {
75+ runCatchingCancellable { client.fileStatus() }
7176 .onSuccess { entries ->
7277 _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
7378 }
@@ -86,8 +91,10 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
8691 }
8792 _state.value = _state.value.copy(path = path, loading = true, error = null)
8893 openJob?.cancel()
94+ searchJob?.cancel()
95+ if (_state.value.isSearching) _state.value = _state.value.copy(results = emptyList(), searching = false)
8996 openJob = viewModelScope.launch {
90− runCatching { client.listDirectory(path) }
97+ runCatchingCancellable { client.listDirectory(path) }
9198 .onSuccess { entries ->
9299 _state.value = _state.value.copy(
93100 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
@@ -120,7 +127,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
120127 searchJob = viewModelScope.launch {
121128 delay(NetworkConfig.fileSearchDebounceMs) // debounce
122129 _state.value = _state.value.copy(searching = true)
123− runCatching { client.findFiles(query) }
130+ runCatchingCancellable { client.findFiles(query) }
124131 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
125132 .onFailure { _state.value = _state.value.copy(searching = false, error = container.friendlyError(it)) }
126133 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+14−10
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.ServerProfile
66import soy.iko.opencode.di.AppContainer
77import soy.iko.opencode.R
8+import soy.iko.opencode.util.runCatchingCancellable
89import kotlinx.coroutines.flow.MutableStateFlow
910import kotlinx.coroutines.flow.SharingStarted
1011import kotlinx.coroutines.flow.StateFlow
@@ -34,22 +35,25 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
3435 _connecting.value = profile.id
3536 _error.value = null
3637 viewModelScope.launch {
37− val result = runCatching {
38− val conn = container.connect(profile)
39− conn.api.ping()
40− }
41− _connecting.value = null
42− result.onSuccess { onConnected() }
43− .onFailure {
44− container.disconnect()
45− _error.value = container.friendlyError(it)
38+ try {
39+ val result = runCatchingCancellable {
40+ val conn = container.connect(profile)
41+ conn.api.ping()
4642 }
43+ result.onSuccess { onConnected() }
44+ .onFailure {
45+ container.disconnect()
46+ _error.value = container.friendlyError(it)
47+ }
48+ } finally {
49+ _connecting.value = null
50+ }
4751 }
4852 }
4953
5054 fun delete(profile: ServerProfile) {
5155 viewModelScope.launch {
52− runCatching { container.profileStore.delete(profile.id) }
56+ runCatchingCancellable { container.profileStore.delete(profile.id) }
5357 .onFailure { _error.value = container.friendlyError(it) }
5458 }
5559 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+7−6
@@ -11,6 +11,7 @@ import soy.iko.opencode.data.network.EventStreamClient
1111import soy.iko.opencode.data.network.NetworkConfig
1212import soy.iko.opencode.di.AppContainer
1313import soy.iko.opencode.R
14+import soy.iko.opencode.util.runCatchingCancellable
1415import kotlinx.coroutines.ExperimentalCoroutinesApi
1516import kotlinx.coroutines.Job
1617import kotlinx.coroutines.flow.MutableStateFlow
@@ -169,7 +170,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
169170 _refreshing.value = true
170171 refreshJob?.cancel()
171172 refreshJob = viewModelScope.launch {
172− runCatching { conn.repository.listSessions() }
173+ runCatchingCancellable { conn.repository.listSessions() }
173174 .onSuccess { list ->
174175 val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
175176 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
@@ -234,7 +235,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
234235 fun createSession(onCreated: (String) -> Unit) {
235236 val conn = container.activeConnection.value ?: return
236237 viewModelScope.launch {
237− runCatching { conn.repository.createSession() }
238+ runCatchingCancellable { conn.repository.createSession() }
238239 .onSuccess { onCreated(it.id); refresh() }
239240 .onFailure {
240241 _state.update { it.copy(error = null) }
@@ -246,7 +247,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
246247 fun deleteSession(session: Session) {
247248 val conn = container.activeConnection.value ?: return
248249 viewModelScope.launch {
249− runCatching { conn.repository.deleteSession(session.id) }
250+ runCatchingCancellable { conn.repository.deleteSession(session.id) }
250251 .onSuccess {
251252 container.draftStore.remove(session.id)
252253 refresh()
@@ -263,7 +264,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
263264 val title = newTitle.trim()
264265 if (title.isEmpty() || title == session.title) return
265266 viewModelScope.launch {
266− runCatching { conn.api.updateSession(session.id, title) }
267+ runCatchingCancellable { conn.api.updateSession(session.id, title) }
267268 .onSuccess { refresh() }
268269 .onFailure {
269270 _state.update { it.copy(error = null) }
@@ -279,7 +280,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
279280 _switchingId.value = profile.id
280281 _state.update { it.copy(loading = true, error = null) }
281282 viewModelScope.launch {
282− val result = runCatching {
283+ val result = runCatchingCancellable {
283284 val conn = container.connect(profile)
284285 conn.api.ping()
285286 }
@@ -301,7 +302,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
301302private suspend fun fetchSessionPreview(
302303 api: soy.iko.opencode.data.network.OpencodeApiClient,
303304 sessionId: String,
304−): String? = runCatching {
305+): String? = runCatchingCancellable {
305306 api.listMessages(sessionId).lastOrNull()?.let { msg ->
306307 msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
307308 }
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+12−1
@@ -49,6 +49,7 @@ fun TwoPaneSessionChat(
4949) {
5050 var selected by rememberSaveable { mutableStateOf<String?>(null) }
5151 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
52+ val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
5253
5354 // A notification tap / deep link requests a session: open it in the detail pane.
5455 LaunchedEffect(pendingOpenSession) {
@@ -58,6 +59,17 @@ fun TwoPaneSessionChat(
5859 }
5960 }
6061
62+ // Inject a pending share into the currently selected session's draft (if any).
63+ // Unlike single-pane mode where the session list is navigated to specifically for
64+ // the share, in two-pane mode the list is always visible — so we inject into the
65+ // session the user is already viewing rather than whichever one they tap next.
66+ LaunchedEffect(pendingShare, selected) {
67+ val target = selected
68+ if (pendingShare != null && target != null) {
69+ container.consumePendingShare()?.let { container.draftStore.set(target, it) }
70+ }
71+ }
72+
6173 Row(modifier = modifier.fillMaxSize()) {
6274 Box(
6375 modifier = Modifier
@@ -67,7 +79,6 @@ fun TwoPaneSessionChat(
6779 SessionListScreen(
6880 container = container,
6981 onOpenSession = { id ->
70− container.consumePendingShare()?.let { container.draftStore.set(id, it) }
7182 selected = id
7283 },
7384 onDisconnect = onDisconnect,
app/src/main/java/soy/iko/opencode/util/Coroutines.ktnew file+17
@@ -0,0 +1,17 @@
1+package soy.iko.opencode.util
2+
3+import kotlinx.coroutines.CancellationException
4+
5+/**
6+ * Like [runCatching] but rethrows [CancellationException] so structured cancellation
7+ * works correctly inside coroutines. Using plain [runCatching] in a coroutine swallows
8+ * cancellation, causing [Result.onFailure] to run on a dying scope and mutate state
9+ * that may never be observed (and defeating cooperative cancellation).
10+ */
11+inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
12+ Result.success(block())
13+} catch (e: CancellationException) {
14+ throw e
15+} catch (e: Throwable) {
16+ Result.failure(e)
17+}
app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt+29
@@ -249,6 +249,35 @@ class MessageStoreTest {
249249 assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
250250 }
251251
252+ // --- seed prune (re-seed after SSE reconnect) ---
253+
254+ @Test
255+ fun seedPruneRemovesMessagesNotInSnapshot() {
256+ val store = MessageStore()
257+ store.seed(listOf(
258+ MessageWithParts(UserMessage("u1", session)),
259+ MessageWithParts(AssistantMessage("a1", session)),
260+ ))
261+ // Re-seed with only u1 — a1 was deleted on the server during the gap.
262+ store.seed(
263+ listOf(MessageWithParts(UserMessage("u1", session))),
264+ prune = true,
265+ )
266+ assertEquals(listOf("u1"), store.snapshot().map { it.info.id })
267+ }
268+
269+ @Test
270+ fun seedWithoutPruneKeepsAllMessages() {
271+ val store = MessageStore()
272+ store.seed(listOf(
273+ MessageWithParts(UserMessage("u1", session)),
274+ MessageWithParts(AssistantMessage("a1", session)),
275+ ))
276+ // Without prune, messages absent from the snapshot are retained.
277+ store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
278+ assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
279+ }
280+
252281 // --- end-to-end ordering ---
253282
254283 @Test