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
8115408iko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+50−35
@@ -67,6 +67,41 @@ class EventStreamClient(
67 /** Request an immediate reconnect, skipping any in-progress backoff. */
67 /** Request an immediate reconnect, skipping any in-progress backoff. */
68 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
68 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
69
69
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+
70 private fun stream(): Flow<BusEvent> = channelFlow {
105 private fun stream(): Flow<BusEvent> = channelFlow {
71 val scope = this
106 val scope = this
72 // Drain any stale reconnect signal from a previous collection cycle so it
107 // Drain any stale reconnect signal from a previous collection cycle so it
@@ -86,10 +121,13 @@ class EventStreamClient(
86 request = {
121 request = {
87 timeout {
122 timeout {
88 requestTimeoutMillis = Long.MAX_VALUE
123 requestTimeoutMillis = Long.MAX_VALUE
89− // Use a finite socket timeout so a server that accepts TCP but
124+ // Disable the socket (read) timeout entirely for the SSE stream.
90− // never sends SSE headers doesn't hang indefinitely. Once events
125+ // A finite value would kill the connection whenever the server
91− // are flowing, the idle-timeout watchdog handles half-open detection.
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.
92− socketTimeoutMillis = NetworkConfig.sseHandshakeTimeoutMs
130+ socketTimeoutMillis = Long.MAX_VALUE
93 }
131 }
94 },
132 },
95 ) {
133 ) {
@@ -102,35 +140,7 @@ class EventStreamClient(
102 // element can be raced against an idle timeout via select.
140 // element can be raced against an idle timeout via select.
103 val events = incoming.produceIn(scope)
141 val events = incoming.produceIn(scope)
104 try {
142 try {
105− while (isActive) {
143+ readSseEvents(scope, events) { send(it) }
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− }
134 } finally {
144 } finally {
135 events.cancel()
145 events.cancel()
136 }
146 }
@@ -138,9 +148,14 @@ class EventStreamClient(
138 } catch (c: CancellationException) {
148 } catch (c: CancellationException) {
139 throw c
149 throw c
140 } catch (e: Exception) {
150 } 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.
141 if (isSseAuthFailure(e)) {
153 if (isSseAuthFailure(e)) {
154+ // Credentials are wrong — retrying won't help. Log and stop.
142− Log.w("EventStream", "SSE auth failed, will retry with backoff", e)
155+ Log.w("EventStream", "SSE auth failed (401/403), stopping retries", e)
156+ _state.value = ConnectionState.Disconnected
157+ break
143− } else {
158+ } else if (isActive) {
144 Log.w("EventStream", "SSE stream error, will retry", e)
159 Log.w("EventStream", "SSE stream error, will retry", e)
145 }
160 }
146 }
161 }
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+2−2
@@ -113,7 +113,7 @@ class ProfileStore(context: Context) {
113 } else {
113 } else {
114 remove(passwordKey(profile.id))
114 remove(passwordKey(profile.id))
115 }
115 }
116− }.apply()
116+ }.commit()
117 }
117 }
118 }
118 }
119
119
@@ -129,7 +129,7 @@ class ProfileStore(context: Context) {
129 )
129 )
130 }
130 }
131 withContext(Dispatchers.IO) {
131 withContext(Dispatchers.IO) {
132− securePrefs.edit().remove(passwordKey(id)).apply()
132+ securePrefs.edit().remove(passwordKey(id)).commit()
133 }
133 }
134 }
134 }
135
135
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+12−2
@@ -81,7 +81,7 @@ class SessionRepository(
81 if (state == EventStreamClient.ConnectionState.Connected) {
81 if (state == EventStreamClient.ConnectionState.Connected) {
82 if (wasConnected) {
82 if (wasConnected) {
83 val fresh = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
83 val fresh = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
84− lock.withLock { store.seed(fresh) }
84+ lock.withLock { store.seed(fresh, prune = true) }
85 publish()
85 publish()
86 }
86 }
87 wasConnected = true
87 wasConnected = true
@@ -121,7 +121,17 @@ internal class MessageStore {
121
121
122 fun snapshot(): List<MessageWithParts> = messages.values.toList()
122 fun snapshot(): List<MessageWithParts> = messages.values.toList()
123
123
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+
125 for (m in initial) {
135 for (m in initial) {
126 val existing = messages[m.info.id]
136 val existing = messages[m.info.id]
127 if (existing == null) {
137 if (existing == null) {
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+8−9
@@ -260,15 +260,14 @@ class AppContainer(context: Context) {
260 OpencodeConnection(finalProfile).also { _activeConnection.value = it }
260 OpencodeConnection(finalProfile).also { _activeConnection.value = it }
261 }
261 }
262
262
263− fun disconnect() {
263+ suspend fun disconnect() {
264− // Launch on appScope so disconnect acquires the connection mutex, preventing
264+ // Acquire the connection mutex so disconnect is serialized with connect().
265− // a race where a concurrent connect() sets a new connection after disconnect
265+ // Previously this was fire-and-forget (launch on appScope), which raced with
266− // reads null — which would leak the just-created connection.
266+ // a subsequent connect(): connect() could acquire the mutex, create a new
267− appScope.launch {
267+ // connection, and then the pending disconnect() would close it.
268− connectionMutex.withLock {
268+ connectionMutex.withLock {
269− _activeConnection.value?.close()
269+ _activeConnection.value?.close()
270− _activeConnection.value = null
270+ _activeConnection.value = null
271− }
272 }
271 }
273 }
272 }
274}
273}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+5−2
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints
4import androidx.compose.runtime.Composable
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.LaunchedEffect
5import androidx.compose.runtime.LaunchedEffect
6import androidx.compose.runtime.getValue
6import androidx.compose.runtime.getValue
7+import androidx.compose.runtime.rememberCoroutineScope
7import androidx.compose.ui.unit.dp
8import androidx.compose.ui.unit.dp
8import androidx.navigation.NavType
9import androidx.navigation.NavType
9import androidx.navigation.compose.NavHost
10import androidx.navigation.compose.NavHost
@@ -11,6 +12,7 @@ import androidx.navigation.compose.composable
11import androidx.navigation.compose.rememberNavController
12import androidx.navigation.compose.rememberNavController
12import androidx.navigation.navArgument
13import androidx.navigation.navArgument
13import androidx.lifecycle.compose.collectAsStateWithLifecycle
14import androidx.lifecycle.compose.collectAsStateWithLifecycle
15+import kotlinx.coroutines.launch
14import soy.iko.opencode.di.AppContainer
16import soy.iko.opencode.di.AppContainer
15import soy.iko.opencode.ui.chat.ChatScreen
17import soy.iko.opencode.ui.chat.ChatScreen
16import soy.iko.opencode.ui.file.FileBrowserScreen
18import soy.iko.opencode.ui.file.FileBrowserScreen
@@ -25,6 +27,7 @@ import soy.iko.opencode.ui.settings.SettingsScreen
25@Composable
27@Composable
26fun OpencodeApp(container: AppContainer) {
28fun OpencodeApp(container: AppContainer) {
27 val navController = rememberNavController()
29 val navController = rememberNavController()
30+ val scope = rememberCoroutineScope()
28 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
31 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
29 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
32 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
30 val connection by container.activeConnection.collectAsStateWithLifecycle()
33 val connection by container.activeConnection.collectAsStateWithLifecycle()
@@ -83,7 +86,7 @@ fun OpencodeApp(container: AppContainer) {
83 onOpenFiles = { navController.navigate(Routes.FILES) },
86 onOpenFiles = { navController.navigate(Routes.FILES) },
84 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
87 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
85 onDisconnect = {
88 onDisconnect = {
86− container.disconnect()
89+ scope.launch { container.disconnect() }
87 navController.popBackStack(Routes.SERVERS, inclusive = false)
90 navController.popBackStack(Routes.SERVERS, inclusive = false)
88 },
91 },
89 )
92 )
@@ -95,7 +98,7 @@ fun OpencodeApp(container: AppContainer) {
95 navController.navigate(Routes.chat(id))
98 navController.navigate(Routes.chat(id))
96 },
99 },
97 onDisconnect = {
100 onDisconnect = {
98− container.disconnect()
101+ scope.launch { container.disconnect() }
99 navController.popBackStack(Routes.SERVERS, inclusive = false)
102 navController.popBackStack(Routes.SERVERS, inclusive = false)
100 },
103 },
101 onOpenFiles = { navController.navigate(Routes.FILES) },
104 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
19import soy.iko.opencode.data.repo.SessionRepository
19import soy.iko.opencode.data.repo.SessionRepository
20import soy.iko.opencode.di.AppContainer
20import soy.iko.opencode.di.AppContainer
21import soy.iko.opencode.R
21import soy.iko.opencode.R
22+import soy.iko.opencode.util.runCatchingCancellable
22import android.util.Log
23import android.util.Log
23import kotlinx.coroutines.ExperimentalCoroutinesApi
24import kotlinx.coroutines.ExperimentalCoroutinesApi
24import kotlinx.coroutines.flow.MutableStateFlow
25import kotlinx.coroutines.flow.MutableStateFlow
@@ -155,7 +156,7 @@ class ChatViewModel(
155 container.activeConnection.collectLatest { conn ->
156 container.activeConnection.collectLatest { conn ->
156 if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; return@collectLatest }
157 if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; return@collectLatest }
157 _modelsLoading.value = true
158 _modelsLoading.value = true
158− runCatching { conn.api.providers() }
159+ runCatchingCancellable { conn.api.providers() }
159 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
160 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
160 .getOrNull()?.let { resp ->
161 .getOrNull()?.let { resp ->
161 val options = resp.toOptions()
162 val options = resp.toOptions()
@@ -170,7 +171,7 @@ class ChatViewModel(
170 container.activeConnection.collectLatest { conn ->
171 container.activeConnection.collectLatest { conn ->
171 if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; return@collectLatest }
172 if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; return@collectLatest }
172 _agentsLoading.value = true
173 _agentsLoading.value = true
173− runCatching { conn.api.agents() }
174+ runCatchingCancellable { conn.api.agents() }
174 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
175 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
175 .getOrNull()?.let { _agents.value = it }
176 .getOrNull()?.let { _agents.value = it }
176 _agentsLoading.value = false
177 _agentsLoading.value = false
@@ -181,7 +182,7 @@ class ChatViewModel(
181 container.activeConnection.collectLatest { conn ->
182 container.activeConnection.collectLatest { conn ->
182 if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; return@collectLatest }
183 if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; return@collectLatest }
183 _commandsLoading.value = true
184 _commandsLoading.value = true
184− runCatching { conn.api.commands() }
185+ runCatchingCancellable { conn.api.commands() }
185 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
186 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
186 .getOrNull()?.let { _commands.value = it }
187 .getOrNull()?.let { _commands.value = it }
187 _commandsLoading.value = false
188 _commandsLoading.value = false
@@ -191,7 +192,7 @@ class ChatViewModel(
191 viewModelScope.launch {
192 viewModelScope.launch {
192 container.activeConnection.collectLatest { conn ->
193 container.activeConnection.collectLatest { conn ->
193 if (conn == null) return@collectLatest
194 if (conn == null) return@collectLatest
194− runCatching { conn.repository.listSessions() }
195+ runCatchingCancellable { conn.repository.listSessions() }
195 .getOrNull()
196 .getOrNull()
196 ?.firstOrNull { it.id == sessionId }
197 ?.firstOrNull { it.id == sessionId }
197 ?.let { _sessionTitle.value = it.displayTitle }
198 ?.let { _sessionTitle.value = it.displayTitle }
@@ -223,7 +224,7 @@ class ChatViewModel(
223 // would be lost forever. The persisted draft is cleared only on success.
224 // would be lost forever. The persisted draft is cleared only on success.
224 _draft.value = ""
225 _draft.value = ""
225 viewModelScope.launch {
226 viewModelScope.launch {
226− runCatching {
227+ runCatchingCancellable {
227 conn.repository.sendPrompt(
228 conn.repository.sendPrompt(
228 sessionId,
229 sessionId,
229 trimmed,
230 trimmed,
@@ -257,7 +258,7 @@ class ChatViewModel(
257 if (!_running.compareAndSet(false, true)) return
258 if (!_running.compareAndSet(false, true)) return
258 _error.value = null
259 _error.value = null
259 viewModelScope.launch {
260 viewModelScope.launch {
260− runCatching {
261+ runCatchingCancellable {
261 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
262 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
262 }.onFailure { _error.value = container.friendlyError(it) }
263 }.onFailure { _error.value = container.friendlyError(it) }
263 _running.value = false
264 _running.value = false
@@ -267,9 +268,9 @@ class ChatViewModel(
267 fun abort() {
268 fun abort() {
268 val conn = connection ?: return
269 val conn = connection ?: return
269 viewModelScope.launch {
270 viewModelScope.launch {
270− runCatching { conn.repository.abort(sessionId) }
271+ runCatchingCancellable { conn.repository.abort(sessionId) }
272+ .onSuccess { _running.value = false }
271 .onFailure { _error.value = container.friendlyError(it) }
273 .onFailure { _error.value = container.friendlyError(it) }
272− _running.value = false
273 }
274 }
274 }
275 }
275
276
@@ -281,7 +282,7 @@ class ChatViewModel(
281 val recent = runCatching { container.profileStore.profiles.first() }
282 val recent = runCatching { container.profileStore.profiles.first() }
282 .getOrDefault(emptyList())
283 .getOrDefault(emptyList())
283 .firstOrNull() ?: run { _loading.value = false; return@launch }
284 .firstOrNull() ?: run { _loading.value = false; return@launch }
284− runCatching {
285+ runCatchingCancellable {
285 val conn = container.connect(recent)
286 val conn = container.connect(recent)
286 conn.api.ping()
287 conn.api.ping()
287 }.onSuccess {
288 }.onSuccess {
@@ -301,10 +302,11 @@ class ChatViewModel(
301 // with a dismissed dialog and a paused tool run.
302 // with a dismissed dialog and a paused tool run.
302 _pendingPermission.value = null
303 _pendingPermission.value = null
303 viewModelScope.launch {
304 viewModelScope.launch {
304− runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
305+ runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
305 .onFailure {
306 .onFailure {
306 _error.value = container.friendlyError(it)
307 _error.value = container.friendlyError(it)
308+ // Only restore if no new permission has arrived in the meantime.
307− _pendingPermission.value = permission
309+ if (_pendingPermission.value == null) _pendingPermission.value = permission
308 }
310 }
309 }
311 }
310 }
312 }
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+3
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.padding
7import androidx.compose.foundation.layout.padding
8+import androidx.activity.compose.BackHandler
8import androidx.compose.material.icons.Icons
9import androidx.compose.material.icons.Icons
9import androidx.compose.material.icons.filled.Shield
10import androidx.compose.material.icons.filled.Shield
10import androidx.compose.material3.AlertDialog
11import androidx.compose.material3.AlertDialog
@@ -35,6 +36,8 @@ fun PermissionDialog(
35 onRespond: (PermissionResponse) -> Unit,
36 onRespond: (PermissionResponse) -> Unit,
36) {
37) {
37 val haptics = LocalHapticFeedback.current
38 val haptics = LocalHapticFeedback.current
39+ // Intercept back so the dialog can't be dismissed without an explicit choice.
40+ BackHandler { onRespond(PermissionResponse.REJECT) }
38 AlertDialog(
41 AlertDialog(
39 onDismissRequest = { /* require an explicit choice */ },
42 onDismissRequest = { /* require an explicit choice */ },
40 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
43 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? {
69 // Resolve relative URL against the base. Collapse any ../ segments to
69 // Resolve relative URL against the base. Collapse any ../ segments to
70 // prevent path traversal from escaping the server's base path.
70 // prevent path traversal from escaping the server's base path.
71 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
71 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
72+ val resolved = runCatching {
72− val resolved = java.net.URI(base).resolve(url).normalize().toString()
73+ java.net.URI(base).resolve(url).normalize().toString()
74+ }.getOrElse { return null }
73− // Guard: if normalization escaped the base path, fall back to the base.
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).
74− if (!resolved.startsWith(base)) base else resolved
77+ if (!resolved.startsWith(base)) null else resolved
75 }
78 }
76}
79}
77
80
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+11−4
@@ -7,6 +7,7 @@ import soy.iko.opencode.data.model.FileStatusEntry
7import soy.iko.opencode.data.network.NetworkConfig
7import soy.iko.opencode.data.network.NetworkConfig
8import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.di.AppContainer
9import soy.iko.opencode.R
9import soy.iko.opencode.R
10+import soy.iko.opencode.util.runCatchingCancellable
10import kotlinx.coroutines.ExperimentalCoroutinesApi
11import kotlinx.coroutines.ExperimentalCoroutinesApi
11import kotlinx.coroutines.Job
12import kotlinx.coroutines.Job
12import kotlinx.coroutines.delay
13import kotlinx.coroutines.delay
@@ -63,11 +64,15 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
63 }
64 }
64 }
65 }
65
66
67+ /** Tracks the in-flight VCS status load so a server switch can cancel it. */
68+ private var statusJob: Job? = null
69+
66 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
70 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
67 private fun loadStatus() {
71 private fun loadStatus() {
68 val client = api ?: return
72 val client = api ?: return
73+ statusJob?.cancel()
69− viewModelScope.launch {
74+ statusJob = viewModelScope.launch {
70− runCatching { client.fileStatus() }
75+ runCatchingCancellable { client.fileStatus() }
71 .onSuccess { entries ->
76 .onSuccess { entries ->
72 _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
77 _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
73 }
78 }
@@ -86,8 +91,10 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
86 }
91 }
87 _state.value = _state.value.copy(path = path, loading = true, error = null)
92 _state.value = _state.value.copy(path = path, loading = true, error = null)
88 openJob?.cancel()
93 openJob?.cancel()
94+ searchJob?.cancel()
95+ if (_state.value.isSearching) _state.value = _state.value.copy(results = emptyList(), searching = false)
89 openJob = viewModelScope.launch {
96 openJob = viewModelScope.launch {
90− runCatching { client.listDirectory(path) }
97+ runCatchingCancellable { client.listDirectory(path) }
91 .onSuccess { entries ->
98 .onSuccess { entries ->
92 _state.value = _state.value.copy(
99 _state.value = _state.value.copy(
93 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
100 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
@@ -120,7 +127,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
120 searchJob = viewModelScope.launch {
127 searchJob = viewModelScope.launch {
121 delay(NetworkConfig.fileSearchDebounceMs) // debounce
128 delay(NetworkConfig.fileSearchDebounceMs) // debounce
122 _state.value = _state.value.copy(searching = true)
129 _state.value = _state.value.copy(searching = true)
123− runCatching { client.findFiles(query) }
130+ runCatchingCancellable { client.findFiles(query) }
124 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
131 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
125 .onFailure { _state.value = _state.value.copy(searching = false, error = container.friendlyError(it)) }
132 .onFailure { _state.value = _state.value.copy(searching = false, error = container.friendlyError(it)) }
126 }
133 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+14−10
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.ServerProfile
5import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.di.AppContainer
6import soy.iko.opencode.di.AppContainer
7import soy.iko.opencode.R
7import soy.iko.opencode.R
8+import soy.iko.opencode.util.runCatchingCancellable
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.SharingStarted
10import kotlinx.coroutines.flow.SharingStarted
10import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.StateFlow
@@ -34,22 +35,25 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
34 _connecting.value = profile.id
35 _connecting.value = profile.id
35 _error.value = null
36 _error.value = null
36 viewModelScope.launch {
37 viewModelScope.launch {
37− val result = runCatching {
38+ try {
39+ val result = runCatchingCancellable {
38− val conn = container.connect(profile)
40+ val conn = container.connect(profile)
39− conn.api.ping()
41+ conn.api.ping()
40− }
41− _connecting.value = null
42− result.onSuccess { onConnected() }
43− .onFailure {
44− container.disconnect()
45− _error.value = container.friendlyError(it)
46 }
42 }
43+ result.onSuccess { onConnected() }
44+ .onFailure {
45+ container.disconnect()
46+ _error.value = container.friendlyError(it)
47+ }
48+ } finally {
49+ _connecting.value = null
50+ }
47 }
51 }
48 }
52 }
49
53
50 fun delete(profile: ServerProfile) {
54 fun delete(profile: ServerProfile) {
51 viewModelScope.launch {
55 viewModelScope.launch {
52− runCatching { container.profileStore.delete(profile.id) }
56+ runCatchingCancellable { container.profileStore.delete(profile.id) }
53 .onFailure { _error.value = container.friendlyError(it) }
57 .onFailure { _error.value = container.friendlyError(it) }
54 }
58 }
55 }
59 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+7−6
@@ -11,6 +11,7 @@ import soy.iko.opencode.data.network.EventStreamClient
11import soy.iko.opencode.data.network.NetworkConfig
11import soy.iko.opencode.data.network.NetworkConfig
12import soy.iko.opencode.di.AppContainer
12import soy.iko.opencode.di.AppContainer
13import soy.iko.opencode.R
13import soy.iko.opencode.R
14+import soy.iko.opencode.util.runCatchingCancellable
14import kotlinx.coroutines.ExperimentalCoroutinesApi
15import kotlinx.coroutines.ExperimentalCoroutinesApi
15import kotlinx.coroutines.Job
16import kotlinx.coroutines.Job
16import kotlinx.coroutines.flow.MutableStateFlow
17import kotlinx.coroutines.flow.MutableStateFlow
@@ -169,7 +170,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
169 _refreshing.value = true
170 _refreshing.value = true
170 refreshJob?.cancel()
171 refreshJob?.cancel()
171 refreshJob = viewModelScope.launch {
172 refreshJob = viewModelScope.launch {
172− runCatching { conn.repository.listSessions() }
173+ runCatchingCancellable { conn.repository.listSessions() }
173 .onSuccess { list ->
174 .onSuccess { list ->
174 val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
175 val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
175 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
176 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
@@ -234,7 +235,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
234 fun createSession(onCreated: (String) -> Unit) {
235 fun createSession(onCreated: (String) -> Unit) {
235 val conn = container.activeConnection.value ?: return
236 val conn = container.activeConnection.value ?: return
236 viewModelScope.launch {
237 viewModelScope.launch {
237− runCatching { conn.repository.createSession() }
238+ runCatchingCancellable { conn.repository.createSession() }
238 .onSuccess { onCreated(it.id); refresh() }
239 .onSuccess { onCreated(it.id); refresh() }
239 .onFailure {
240 .onFailure {
240 _state.update { it.copy(error = null) }
241 _state.update { it.copy(error = null) }
@@ -246,7 +247,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
246 fun deleteSession(session: Session) {
247 fun deleteSession(session: Session) {
247 val conn = container.activeConnection.value ?: return
248 val conn = container.activeConnection.value ?: return
248 viewModelScope.launch {
249 viewModelScope.launch {
249− runCatching { conn.repository.deleteSession(session.id) }
250+ runCatchingCancellable { conn.repository.deleteSession(session.id) }
250 .onSuccess {
251 .onSuccess {
251 container.draftStore.remove(session.id)
252 container.draftStore.remove(session.id)
252 refresh()
253 refresh()
@@ -263,7 +264,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
263 val title = newTitle.trim()
264 val title = newTitle.trim()
264 if (title.isEmpty() || title == session.title) return
265 if (title.isEmpty() || title == session.title) return
265 viewModelScope.launch {
266 viewModelScope.launch {
266− runCatching { conn.api.updateSession(session.id, title) }
267+ runCatchingCancellable { conn.api.updateSession(session.id, title) }
267 .onSuccess { refresh() }
268 .onSuccess { refresh() }
268 .onFailure {
269 .onFailure {
269 _state.update { it.copy(error = null) }
270 _state.update { it.copy(error = null) }
@@ -279,7 +280,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
279 _switchingId.value = profile.id
280 _switchingId.value = profile.id
280 _state.update { it.copy(loading = true, error = null) }
281 _state.update { it.copy(loading = true, error = null) }
281 viewModelScope.launch {
282 viewModelScope.launch {
282− val result = runCatching {
283+ val result = runCatchingCancellable {
283 val conn = container.connect(profile)
284 val conn = container.connect(profile)
284 conn.api.ping()
285 conn.api.ping()
285 }
286 }
@@ -301,7 +302,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
301private suspend fun fetchSessionPreview(
302private suspend fun fetchSessionPreview(
302 api: soy.iko.opencode.data.network.OpencodeApiClient,
303 api: soy.iko.opencode.data.network.OpencodeApiClient,
303 sessionId: String,
304 sessionId: String,
304−): String? = runCatching {
305+): String? = runCatchingCancellable {
305 api.listMessages(sessionId).lastOrNull()?.let { msg ->
306 api.listMessages(sessionId).lastOrNull()?.let { msg ->
306 msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
307 msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
307 }
308 }
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+12−1
@@ -49,6 +49,7 @@ fun TwoPaneSessionChat(
49) {
49) {
50 var selected by rememberSaveable { mutableStateOf<String?>(null) }
50 var selected by rememberSaveable { mutableStateOf<String?>(null) }
51 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
51 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
52+ val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
52
53
53 // A notification tap / deep link requests a session: open it in the detail pane.
54 // A notification tap / deep link requests a session: open it in the detail pane.
54 LaunchedEffect(pendingOpenSession) {
55 LaunchedEffect(pendingOpenSession) {
@@ -58,6 +59,17 @@ fun TwoPaneSessionChat(
58 }
59 }
59 }
60 }
60
61
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+
61 Row(modifier = modifier.fillMaxSize()) {
73 Row(modifier = modifier.fillMaxSize()) {
62 Box(
74 Box(
63 modifier = Modifier
75 modifier = Modifier
@@ -67,7 +79,6 @@ fun TwoPaneSessionChat(
67 SessionListScreen(
79 SessionListScreen(
68 container = container,
80 container = container,
69 onOpenSession = { id ->
81 onOpenSession = { id ->
70− container.consumePendingShare()?.let { container.draftStore.set(id, it) }
71 selected = id
82 selected = id
72 },
83 },
73 onDisconnect = onDisconnect,
84 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 {
249 assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
249 assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
250 }
250 }
251
251
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+
252 // --- end-to-end ordering ---
281 // --- end-to-end ordering ---
253
282
254 @Test
283 @Test