git.iko.soy/opencode-android
on master help sign in
Harden shutdown ANR, fragile Job assertion, unsafe casts, stale markdown rendering, and centralize magic numbers and catalog-loading boilerplate
commit363f751153dd3af5d098841f8c2b8473d31cc2d5 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 10:07 (3 months ago) parent8948962
14 files changed+184−90
app/src/main/java/soy/iko/opencode/data/model/Permission.kt+1−1 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+1−1 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+38 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+14−5 app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+2−3 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+2−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+108−68 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+2−1 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+5−2 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+3−2 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+2−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+2−2 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+3−2 app/src/main/java/soy/iko/opencode/util/Coroutines.kt+1−1
app/src/main/java/soy/iko/opencode/data/model/Permission.kt+1−1
@@ -27,7 +27,7 @@ data class Permission(
2727 get() = when (val p = pattern) {
2828 null -> null
2929 is JsonPrimitive -> p.content
30− else -> runCatching { p.jsonArray.joinToString(", ") { (it as JsonPrimitive).content } }.getOrNull()
30+ else -> p.jsonArray.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
3131 }
3232}
3333
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+1−1
@@ -56,7 +56,7 @@ class EventStreamClient(
5656 .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
5757 .shareIn(
5858 scope = scope,
59− started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
59+ started = SharingStarted.WhileSubscribed(stopTimeoutMillis = NetworkConfig.stateFlowSubscriptionTimeoutMs),
6060 replay = 0,
6161 )
6262
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+38
@@ -63,4 +63,42 @@ object NetworkConfig {
6363
6464 /** Debounce delay before firing a file-search request after the user stops typing. */
6565 const val fileSearchDebounceMs = 250L
66+
67+ // --- StateFlow sharing (WhileSubscribed) ---
68+
69+ /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
70+ const val stateFlowSubscriptionTimeoutMs = 5_000L
71+
72+ // --- Draft persistence (ChatViewModel) ---
73+
74+ /** Debounce delay before persisting a draft to disk after the user stops typing. */
75+ const val draftDebounceMs = 500L
76+
77+ // --- Profile store (ServerEditViewModel) ---
78+
79+ /** Timeout for loading a profile from DataStore before giving up. */
80+ const val profileLoadTimeoutMs = 5_000L
81+
82+ // --- SSE stream (EventStreamClient, AppContainer) ---
83+
84+ /** Delay before retrying the message-activity observer after a failure. */
85+ const val observerRetryDelayMs = 5_000L
86+ /** Max concurrent assistant runs to track for completion notifications. */
87+ const val activeRunsLimit = 200
88+
89+ // --- Markdown rendering (MarkdownText) ---
90+
91+ /** Throttle delay (~60fps frame) to coalesce streaming tokens into one re-parse. */
92+ const val streamingThrottleMs = 16L
93+
94+ // --- UI layout constants ---
95+
96+ /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
97+ const val twoPaneWidthThresholdDp = 840
98+ /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
99+ const val userBubbleWidthFraction = 0.85f
100+ /** Left pane weight in the two-pane layout (session list). */
101+ const val twoPaneLeftWeight = 0.38f
102+ /** Right pane weight in the two-pane layout (chat detail). */
103+ const val twoPaneRightWeight = 0.62f
66104}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+14−5
@@ -16,6 +16,7 @@ import soy.iko.opencode.data.repo.ProfileStore
1616import soy.iko.opencode.data.repo.SettingsStore
1717import soy.iko.opencode.data.repo.classifyError
1818import soy.iko.opencode.data.repo.responseStatusCode
19+import soy.iko.opencode.data.network.NetworkConfig
1920import soy.iko.opencode.notification.NotificationChannels
2021import soy.iko.opencode.notification.SessionNotifications
2122import soy.iko.opencode.R
@@ -37,6 +38,7 @@ import kotlinx.coroutines.isActive
3738import kotlinx.coroutines.launch
3839import kotlinx.coroutines.sync.Mutex
3940import kotlinx.coroutines.sync.withLock
41+import kotlinx.coroutines.withTimeoutOrNull
4042import java.util.concurrent.atomic.AtomicBoolean
4143
4244/**
@@ -155,11 +157,18 @@ class AppContainer(context: Context) {
155157 // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
156158 // that can't be cancelled until after the runBlocking completes.
157159 appScope.cancel()
160+ // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
161+ // 2 seconds is generous: appScope.cancel() already triggered cancellation of
162+ // any coroutine holding the mutex; we're just waiting for it to unwind.
158163 kotlinx.coroutines.runBlocking {
159− connectionMutex.withLock {
160− runCatching { _activeConnection.value?.close() }
161− _activeConnection.value = null
164+ withTimeoutOrNull(2_000) {
165+ connectionMutex.withLock {
166+ runCatching { _activeConnection.value?.close() }
167+ _activeConnection.value = null
168+ }
162169 }
170+ // If the timeout fired, force-clear the connection so it isn't left dangling.
171+ _activeConnection.value = null
163172 }
164173 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
165174 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
@@ -214,7 +223,7 @@ class AppContainer(context: Context) {
214223 }
215224 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry", it) }
216225 if (!isActive) break
217− delay(5_000)
226+ delay(NetworkConfig.observerRetryDelayMs)
218227 }
219228 }
220229 }
@@ -223,7 +232,7 @@ class AppContainer(context: Context) {
223232 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
224233
225234 /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
226− private val activeRunsLimit = 200
235+ private val activeRunsLimit = NetworkConfig.activeRunsLimit
227236
228237 /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
229238 private val connectionMutex = Mutex()
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+2−3
@@ -7,7 +7,6 @@ import soy.iko.opencode.data.network.OpencodeApiClient
77import soy.iko.opencode.data.repo.SessionRepository
88import kotlinx.coroutines.CoroutineScope
99import kotlinx.coroutines.Dispatchers
10−import kotlinx.coroutines.Job
1110import kotlinx.coroutines.SupervisorJob
1211import kotlinx.coroutines.cancel
1312
@@ -18,8 +17,8 @@ import kotlinx.coroutines.cancel
1817 */
1918class OpencodeConnection(val profile: ServerProfile) {
2019
21− private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
22− private val scopeJob: Job get() = scope.coroutineContext[Job]!!
20+ private val scopeJob = SupervisorJob()
21+ private val scope = CoroutineScope(scopeJob + Dispatchers.IO)
2322
2423 private val client = HttpClientFactory.create(profile)
2524 val api = OpencodeApiClient(client)
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+2−1
@@ -14,6 +14,7 @@ import androidx.navigation.navArgument
1414import androidx.lifecycle.compose.collectAsStateWithLifecycle
1515import kotlinx.coroutines.launch
1616import soy.iko.opencode.di.AppContainer
17+import soy.iko.opencode.data.network.NetworkConfig
1718import soy.iko.opencode.ui.chat.ChatScreen
1819import soy.iko.opencode.ui.file.FileBrowserScreen
1920import soy.iko.opencode.ui.file.FileViewScreen
@@ -48,7 +49,7 @@ fun OpencodeApp(container: AppContainer) {
4849 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
4950 // the open conversation side by side instead of a single-pane back stack.
5051 BoxWithConstraints {
51− val isTwoPane = maxWidth >= 840.dp && connection != null
52+ val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
5253
5354 // Open a session requested by a notification tap or deep link, once connected.
5455 // In two-pane mode the detail pane consumes the request instead of navigating.
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+108−68
@@ -16,6 +16,7 @@ import soy.iko.opencode.data.model.TextPart
1616import soy.iko.opencode.data.model.defaultOption
1717import soy.iko.opencode.data.model.toOptions
1818import soy.iko.opencode.data.network.EventStreamClient
19+import soy.iko.opencode.data.network.NetworkConfig
1920import soy.iko.opencode.data.repo.SessionRepository
2021import soy.iko.opencode.di.AppContainer
2122import soy.iko.opencode.R
@@ -62,7 +63,7 @@ class ChatViewModel(
6263 }
6364 .stateIn(
6465 scope = viewModelScope,
65− started = SharingStarted.WhileSubscribed(5_000),
66+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
6667 initialValue = emptyList(),
6768 )
6869
@@ -89,7 +90,7 @@ class ChatViewModel(
8990 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
9091 .stateIn(
9192 scope = viewModelScope,
92− started = SharingStarted.WhileSubscribed(5_000),
93+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
9394 initialValue = EventStreamClient.ConnectionState.Disconnected,
9495 )
9596
@@ -153,44 +154,92 @@ class ChatViewModel(
153154
154155 fun reloadModels() {
155156 val conn = connection ?: return
156− viewModelScope.launch {
157− conn.api.invalidateCache()
158− _modelsLoading.value = true
159− _modelsError.value = false
160− runCatchingCancellable { conn.api.providers() }
161− .onFailure { Log.w("ChatViewModel", "Failed to reload model catalog", it); _modelsError.value = true }
162− .getOrNull()?.let { resp ->
163− val options = resp.toOptions()
164− _models.value = options
165− _selectedModel.value = resp.defaultOption(options)
166− }
167− _modelsLoading.value = false
168− }
157+ reloadCatalog(
158+ conn = conn,
159+ tag = "model catalog",
160+ loading = _modelsLoading,
161+ error = _modelsError,
162+ fetch = { it.providers() },
163+ onSuccess = { resp ->
164+ val options = resp.toOptions()
165+ _models.value = options
166+ _selectedModel.value = resp.defaultOption(options)
167+ },
168+ )
169169 }
170170
171171 fun reloadAgents() {
172172 val conn = connection ?: return
173− viewModelScope.launch {
174− conn.api.invalidateCache()
175− _agentsLoading.value = true
176− _agentsError.value = false
177− runCatchingCancellable { conn.api.agents() }
178− .onFailure { Log.w("ChatViewModel", "Failed to reload agent catalog", it); _agentsError.value = true }
179− .getOrNull()?.let { _agents.value = it }
180− _agentsLoading.value = false
181− }
173+ reloadCatalog(
174+ conn = conn,
175+ tag = "agent catalog",
176+ loading = _agentsLoading,
177+ error = _agentsError,
178+ fetch = { it.agents() },
179+ onSuccess = { _agents.value = it },
180+ )
182181 }
183182
184183 fun reloadCommands() {
185184 val conn = connection ?: return
185+ reloadCatalog(
186+ conn = conn,
187+ tag = "command catalog",
188+ loading = _commandsLoading,
189+ error = _commandsError,
190+ fetch = { it.commands() },
191+ onSuccess = { _commands.value = it },
192+ )
193+ }
194+
195+ /**
196+ * Shared helper for reloadXxx(): invalidates the API cache, sets loading/error
197+ * state, and applies [onSuccess] to the fetched result. Reduces triplicated
198+ * boilerplate that was easy to get out of sync.
199+ */
200+ private fun <T> reloadCatalog(
201+ conn: soy.iko.opencode.di.OpencodeConnection,
202+ tag: String,
203+ loading: MutableStateFlow<Boolean>,
204+ error: MutableStateFlow<Boolean>,
205+ fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
206+ onSuccess: (T) -> Unit,
207+ ) {
186208 viewModelScope.launch {
187209 conn.api.invalidateCache()
188− _commandsLoading.value = true
189− _commandsError.value = false
190− runCatchingCancellable { conn.api.commands() }
191− .onFailure { Log.w("ChatViewModel", "Failed to reload command catalog", it); _commandsError.value = true }
192− .getOrNull()?.let { _commands.value = it }
193− _commandsLoading.value = false
210+ loading.value = true
211+ error.value = false
212+ runCatchingCancellable { fetch(conn.api) }
213+ .onFailure { Log.w("ChatViewModel", "Failed to reload $tag", it); error.value = true }
214+ .getOrNull()?.let(onSuccess)
215+ loading.value = false
216+ }
217+ }
218+
219+ /**
220+ * Shared helper for init-block catalog observers: re-fetches whenever the active
221+ * connection changes, invoking [onNull] to clear state on null so stale data from
222+ * the old server doesn't persist. Failure is non-fatal — the error flag is surfaced
223+ * to the UI.
224+ */
225+ private fun <T> observeCatalog(
226+ tag: String,
227+ loading: MutableStateFlow<Boolean>,
228+ error: MutableStateFlow<Boolean>,
229+ fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
230+ onSuccess: (T) -> Unit,
231+ onNull: () -> Unit,
232+ ) {
233+ viewModelScope.launch {
234+ container.activeConnection.collectLatest { conn ->
235+ if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
236+ loading.value = true
237+ error.value = false
238+ runCatchingCancellable { fetch(conn.api) }
239+ .onFailure { Log.w("ChatViewModel", "Failed to load $tag", it); error.value = true }
240+ .getOrNull()?.let(onSuccess)
241+ loading.value = false
242+ }
194243 }
195244 }
196245
@@ -201,7 +250,7 @@ class ChatViewModel(
201250 init {
202251 // Debounce draft persistence so we don't write to disk on every keystroke.
203252 viewModelScope.launch {
204− _draft.drop(1).debounce(500).collect { text ->
253+ _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
205254 runCatchingCancellable { container.draftStore.set(sessionId, text) }
206255 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
207256 }
@@ -241,45 +290,36 @@ class ChatViewModel(
241290 }
242291 // Load the model catalog; preselect the server default. Failure is non-fatal —
243292 // sending with no model just uses the server's default agent/model.
244− viewModelScope.launch {
245− container.activeConnection.collectLatest { conn ->
246− if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; _modelsError.value = false; return@collectLatest }
247− _modelsLoading.value = true
248− _modelsError.value = false
249− runCatchingCancellable { conn.api.providers() }
250− .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it); _modelsError.value = true }
251− .getOrNull()?.let { resp ->
252− val options = resp.toOptions()
253− _models.value = options
254− _selectedModel.value = resp.defaultOption(options)
255− }
256− _modelsLoading.value = false
257− }
258− }
293+ observeCatalog(
294+ tag = "model catalog",
295+ loading = _modelsLoading,
296+ error = _modelsError,
297+ fetch = { it.providers() },
298+ onSuccess = { resp ->
299+ val options = resp.toOptions()
300+ _models.value = options
301+ _selectedModel.value = resp.defaultOption(options)
302+ },
303+ onNull = { _models.value = emptyList(); _selectedModel.value = null },
304+ )
259305 // Load the agent catalog (non-fatal).
260− viewModelScope.launch {
261− container.activeConnection.collectLatest { conn ->
262− if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; _agentsError.value = false; return@collectLatest }
263− _agentsLoading.value = true
264− _agentsError.value = false
265− runCatchingCancellable { conn.api.agents() }
266− .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it); _agentsError.value = true }
267− .getOrNull()?.let { _agents.value = it }
268− _agentsLoading.value = false
269− }
270− }
306+ observeCatalog(
307+ tag = "agent catalog",
308+ loading = _agentsLoading,
309+ error = _agentsError,
310+ fetch = { it.agents() },
311+ onSuccess = { _agents.value = it },
312+ onNull = { _agents.value = emptyList() },
313+ )
271314 // Load the command catalog (non-fatal).
272− viewModelScope.launch {
273− container.activeConnection.collectLatest { conn ->
274− if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; _commandsError.value = false; return@collectLatest }
275− _commandsLoading.value = true
276− _commandsError.value = false
277− runCatchingCancellable { conn.api.commands() }
278− .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it); _commandsError.value = true }
279− .getOrNull()?.let { _commands.value = it }
280− _commandsLoading.value = false
281− }
282− }
315+ observeCatalog(
316+ tag = "command catalog",
317+ loading = _commandsLoading,
318+ error = _commandsError,
319+ fetch = { it.commands() },
320+ onSuccess = { _commands.value = it },
321+ onNull = { _commands.value = emptyList() },
322+ )
283323 // Resolve the human-readable session title for the app bar (non-fatal).
284324 viewModelScope.launch {
285325 container.activeConnection.collectLatest { conn ->
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+2−1
@@ -20,6 +20,7 @@ import soy.iko.opencode.data.model.AssistantMessage
2020import soy.iko.opencode.data.model.MessageWithParts
2121import soy.iko.opencode.data.model.Tokens
2222import soy.iko.opencode.data.model.UserMessage
23+import soy.iko.opencode.data.network.NetworkConfig
2324import soy.iko.opencode.ui.components.ImageLoadContext
2425import soy.iko.opencode.ui.components.rememberRelativeTime
2526
@@ -42,7 +43,7 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
4243 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
4344 Column(
4445 modifier = Modifier
45− .fillMaxWidth(0.85f)
46+ .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
4647 .clip(RoundedCornerShape(16.dp))
4748 .background(MaterialTheme.colorScheme.primaryContainer)
4849 .padding(12.dp),
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+5−2
@@ -43,6 +43,7 @@ import org.intellij.markdown.MarkdownElementTypes
4343import org.intellij.markdown.ast.ASTNode
4444import org.intellij.markdown.ast.getTextInNode
4545import soy.iko.opencode.R
46+import soy.iko.opencode.data.network.NetworkConfig
4647
4748/**
4849 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
@@ -67,13 +68,15 @@ fun MarkdownText(
6768 // into a single markdown re-parse per frame instead of one per token. The delay
6869 // is only applied when the content is growing incrementally (streaming) — a
6970 // content switch or initial render proceeds immediately.
70− var renderedContent by remember { mutableStateOf(markdown) }
71+ // Keyed to a content-prefix so switching to a different message resets
72+ // immediately instead of showing the old message for one frame.
73+ var renderedContent by remember(markdown.take(32)) { mutableStateOf(markdown) }
7174 LaunchedEffect(markdown) {
7275 if (renderedContent.isNotEmpty() &&
7376 markdown.startsWith(renderedContent) &&
7477 markdown != renderedContent
7578 ) {
76− delay(16)
79+ delay(NetworkConfig.streamingThrottleMs)
7780 }
7881 renderedContent = markdown
7982 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+3−2
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
44import androidx.lifecycle.ViewModel
55import androidx.lifecycle.viewModelScope
66import soy.iko.opencode.data.model.ServerProfile
7+import soy.iko.opencode.data.network.NetworkConfig
78import soy.iko.opencode.di.AppContainer
89import kotlinx.coroutines.flow.MutableStateFlow
910import kotlinx.coroutines.flow.StateFlow
@@ -50,7 +51,7 @@ class ServerEditViewModel(
5051 if (profileId != null) {
5152 // Wait for a non-empty profile list before searching, so DataStore
5253 // emitting an empty list during loading doesn't cause a false timeout.
53− val existing = withTimeoutOrNull(5_000) {
54+ val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
5455 container.profileStore.profiles
5556 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
5657 .firstOrNull { it.id == profileId }
@@ -90,7 +91,7 @@ class ServerEditViewModel(
9091 viewModelScope.launch {
9192 val result = runCatchingCancellable {
9293 val existingLastUsed = if (s.id != null) {
93− withTimeoutOrNull(5_000) {
94+ withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
9495 container.profileStore.profiles.first()
9596 .firstOrNull { it.id == s.id }?.lastUsed
9697 } ?: 0L
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+2−1
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.server
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.ServerProfile
6+import soy.iko.opencode.data.network.NetworkConfig
67import soy.iko.opencode.di.AppContainer
78import soy.iko.opencode.R
89import soy.iko.opencode.util.runCatchingCancellable
@@ -18,7 +19,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
1819 val profiles: StateFlow<List<ServerProfile>> =
1920 container.profileStore.profiles.stateIn(
2021 scope = viewModelScope,
21− started = SharingStarted.WhileSubscribed(5_000),
22+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
2223 initialValue = emptyList(),
2324 )
2425
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+2−2
@@ -58,7 +58,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
5858 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
5959 .stateIn(
6060 scope = viewModelScope,
61− started = SharingStarted.WhileSubscribed(5_000),
61+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
6262 initialValue = EventStreamClient.ConnectionState.Disconnected,
6363 )
6464
@@ -68,7 +68,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
6868 val profiles: StateFlow<List<ServerProfile>> =
6969 container.profileStore.profiles.stateIn(
7070 scope = viewModelScope,
71− started = SharingStarted.WhileSubscribed(5_000),
71+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
7272 initialValue = emptyList(),
7373 )
7474
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+3−2
@@ -25,6 +25,7 @@ import androidx.compose.ui.res.stringResource
2525import androidx.compose.ui.unit.dp
2626import androidx.lifecycle.compose.collectAsStateWithLifecycle
2727import soy.iko.opencode.R
28+import soy.iko.opencode.data.network.NetworkConfig
2829import soy.iko.opencode.di.AppContainer
2930import soy.iko.opencode.ui.chat.ChatScreen
3031
@@ -74,7 +75,7 @@ fun TwoPaneSessionChat(
7475 Row(modifier = modifier.fillMaxSize()) {
7576 Box(
7677 modifier = Modifier
77− .weight(0.38f)
78+ .weight(NetworkConfig.twoPaneLeftWeight)
7879 .widthIn(max = 460.dp),
7980 ) {
8081 SessionListScreen(
@@ -88,7 +89,7 @@ fun TwoPaneSessionChat(
8889 )
8990 }
9091
91− Box(modifier = Modifier.weight(0.62f).fillMaxSize()) {
92+ Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
9293 val sessionId = selected
9394 if (sessionId == null) {
9495 EmptyDetail()
app/src/main/java/soy/iko/opencode/util/Coroutines.kt+1−1
@@ -12,6 +12,6 @@ inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
1212 Result.success(block())
1313} catch (e: CancellationException) {
1414 throw e
15−} catch (e: Throwable) {
15+} catch (e: Exception) {
1616 Result.failure(e)
1717}