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
363f751iko
app/src/main/java/soy/iko/opencode/data/model/Permission.kt+1−1
@@ -27,7 +27,7 @@ data class Permission(
27 get() = when (val p = pattern) {
27 get() = when (val p = pattern) {
28 null -> null
28 null -> null
29 is JsonPrimitive -> p.content
29 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 }
31 }
31 }
32}
32}
33
33
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+1−1
@@ -56,7 +56,7 @@ class EventStreamClient(
56 .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
56 .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
57 .shareIn(
57 .shareIn(
58 scope = scope,
58 scope = scope,
59− started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
59+ started = SharingStarted.WhileSubscribed(stopTimeoutMillis = NetworkConfig.stateFlowSubscriptionTimeoutMs),
60 replay = 0,
60 replay = 0,
61 )
61 )
62
62
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+38
@@ -63,4 +63,42 @@ object NetworkConfig {
63
63
64 /** Debounce delay before firing a file-search request after the user stops typing. */
64 /** Debounce delay before firing a file-search request after the user stops typing. */
65 const val fileSearchDebounceMs = 250L
65 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
66}
104}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+14−5
@@ -16,6 +16,7 @@ import soy.iko.opencode.data.repo.ProfileStore
16import soy.iko.opencode.data.repo.SettingsStore
16import soy.iko.opencode.data.repo.SettingsStore
17import soy.iko.opencode.data.repo.classifyError
17import soy.iko.opencode.data.repo.classifyError
18import soy.iko.opencode.data.repo.responseStatusCode
18import soy.iko.opencode.data.repo.responseStatusCode
19+import soy.iko.opencode.data.network.NetworkConfig
19import soy.iko.opencode.notification.NotificationChannels
20import soy.iko.opencode.notification.NotificationChannels
20import soy.iko.opencode.notification.SessionNotifications
21import soy.iko.opencode.notification.SessionNotifications
21import soy.iko.opencode.R
22import soy.iko.opencode.R
@@ -37,6 +38,7 @@ import kotlinx.coroutines.isActive
37import kotlinx.coroutines.launch
38import kotlinx.coroutines.launch
38import kotlinx.coroutines.sync.Mutex
39import kotlinx.coroutines.sync.Mutex
39import kotlinx.coroutines.sync.withLock
40import kotlinx.coroutines.sync.withLock
41+import kotlinx.coroutines.withTimeoutOrNull
40import java.util.concurrent.atomic.AtomicBoolean
42import java.util.concurrent.atomic.AtomicBoolean
41
43
42/**
44/**
@@ -155,11 +157,18 @@ class AppContainer(context: Context) {
155 // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
157 // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
156 // that can't be cancelled until after the runBlocking completes.
158 // that can't be cancelled until after the runBlocking completes.
157 appScope.cancel()
159 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.
158 kotlinx.coroutines.runBlocking {
163 kotlinx.coroutines.runBlocking {
164+ withTimeoutOrNull(2_000) {
159− connectionMutex.withLock {
165+ connectionMutex.withLock {
160− runCatching { _activeConnection.value?.close() }
166+ runCatching { _activeConnection.value?.close() }
161− _activeConnection.value = null
167+ _activeConnection.value = null
168+ }
162 }
169 }
170+ // If the timeout fired, force-clear the connection so it isn't left dangling.
171+ _activeConnection.value = null
163 }
172 }
164 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
173 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
165 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
174 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
@@ -214,7 +223,7 @@ class AppContainer(context: Context) {
214 }
223 }
215 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry", it) }
224 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry", it) }
216 if (!isActive) break
225 if (!isActive) break
217− delay(5_000)
226+ delay(NetworkConfig.observerRetryDelayMs)
218 }
227 }
219 }
228 }
220 }
229 }
@@ -223,7 +232,7 @@ class AppContainer(context: Context) {
223 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
232 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
224
233
225 /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
234 /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
226− private val activeRunsLimit = 200
235+ private val activeRunsLimit = NetworkConfig.activeRunsLimit
227
236
228 /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
237 /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
229 private val connectionMutex = Mutex()
238 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
7import soy.iko.opencode.data.repo.SessionRepository
7import soy.iko.opencode.data.repo.SessionRepository
8import kotlinx.coroutines.CoroutineScope
8import kotlinx.coroutines.CoroutineScope
9import kotlinx.coroutines.Dispatchers
9import kotlinx.coroutines.Dispatchers
10−import kotlinx.coroutines.Job
11import kotlinx.coroutines.SupervisorJob
10import kotlinx.coroutines.SupervisorJob
12import kotlinx.coroutines.cancel
11import kotlinx.coroutines.cancel
13
12
@@ -18,8 +17,8 @@ import kotlinx.coroutines.cancel
18 */
17 */
19class OpencodeConnection(val profile: ServerProfile) {
18class OpencodeConnection(val profile: ServerProfile) {
20
19
20+ private val scopeJob = SupervisorJob()
21− private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
21+ private val scope = CoroutineScope(scopeJob + Dispatchers.IO)
22− private val scopeJob: Job get() = scope.coroutineContext[Job]!!
23
22
24 private val client = HttpClientFactory.create(profile)
23 private val client = HttpClientFactory.create(profile)
25 val api = OpencodeApiClient(client)
24 val api = OpencodeApiClient(client)
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+2−1
@@ -14,6 +14,7 @@ import androidx.navigation.navArgument
14import androidx.lifecycle.compose.collectAsStateWithLifecycle
14import androidx.lifecycle.compose.collectAsStateWithLifecycle
15import kotlinx.coroutines.launch
15import kotlinx.coroutines.launch
16import soy.iko.opencode.di.AppContainer
16import soy.iko.opencode.di.AppContainer
17+import soy.iko.opencode.data.network.NetworkConfig
17import soy.iko.opencode.ui.chat.ChatScreen
18import soy.iko.opencode.ui.chat.ChatScreen
18import soy.iko.opencode.ui.file.FileBrowserScreen
19import soy.iko.opencode.ui.file.FileBrowserScreen
19import soy.iko.opencode.ui.file.FileViewScreen
20import soy.iko.opencode.ui.file.FileViewScreen
@@ -48,7 +49,7 @@ fun OpencodeApp(container: AppContainer) {
48 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
49 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
49 // the open conversation side by side instead of a single-pane back stack.
50 // the open conversation side by side instead of a single-pane back stack.
50 BoxWithConstraints {
51 BoxWithConstraints {
51− val isTwoPane = maxWidth >= 840.dp && connection != null
52+ val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
52
53
53 // Open a session requested by a notification tap or deep link, once connected.
54 // Open a session requested by a notification tap or deep link, once connected.
54 // In two-pane mode the detail pane consumes the request instead of navigating.
55 // 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
16import soy.iko.opencode.data.model.defaultOption
16import soy.iko.opencode.data.model.defaultOption
17import soy.iko.opencode.data.model.toOptions
17import soy.iko.opencode.data.model.toOptions
18import soy.iko.opencode.data.network.EventStreamClient
18import soy.iko.opencode.data.network.EventStreamClient
19+import soy.iko.opencode.data.network.NetworkConfig
19import soy.iko.opencode.data.repo.SessionRepository
20import soy.iko.opencode.data.repo.SessionRepository
20import soy.iko.opencode.di.AppContainer
21import soy.iko.opencode.di.AppContainer
21import soy.iko.opencode.R
22import soy.iko.opencode.R
@@ -62,7 +63,7 @@ class ChatViewModel(
62 }
63 }
63 .stateIn(
64 .stateIn(
64 scope = viewModelScope,
65 scope = viewModelScope,
65− started = SharingStarted.WhileSubscribed(5_000),
66+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
66 initialValue = emptyList(),
67 initialValue = emptyList(),
67 )
68 )
68
69
@@ -89,7 +90,7 @@ class ChatViewModel(
89 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
90 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
90 .stateIn(
91 .stateIn(
91 scope = viewModelScope,
92 scope = viewModelScope,
92− started = SharingStarted.WhileSubscribed(5_000),
93+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
93 initialValue = EventStreamClient.ConnectionState.Disconnected,
94 initialValue = EventStreamClient.ConnectionState.Disconnected,
94 )
95 )
95
96
@@ -153,44 +154,92 @@ class ChatViewModel(
153
154
154 fun reloadModels() {
155 fun reloadModels() {
155 val conn = connection ?: return
156 val conn = connection ?: return
156− viewModelScope.launch {
157+ reloadCatalog(
157− conn.api.invalidateCache()
158+ conn = conn,
158− _modelsLoading.value = true
159+ tag = "model catalog",
159− _modelsError.value = false
160+ loading = _modelsLoading,
160− runCatchingCancellable { conn.api.providers() }
161+ error = _modelsError,
161− .onFailure { Log.w("ChatViewModel", "Failed to reload model catalog", it); _modelsError.value = true }
162+ fetch = { it.providers() },
162− .getOrNull()?.let { resp ->
163+ onSuccess = { resp ->
163− val options = resp.toOptions()
164+ val options = resp.toOptions()
164− _models.value = options
165+ _models.value = options
165− _selectedModel.value = resp.defaultOption(options)
166+ _selectedModel.value = resp.defaultOption(options)
166− }
167+ },
167− _modelsLoading.value = false
168+ )
168− }
169 }
169 }
170
170
171 fun reloadAgents() {
171 fun reloadAgents() {
172 val conn = connection ?: return
172 val conn = connection ?: return
173− viewModelScope.launch {
173+ reloadCatalog(
174− conn.api.invalidateCache()
174+ conn = conn,
175− _agentsLoading.value = true
175+ tag = "agent catalog",
176− _agentsError.value = false
176+ loading = _agentsLoading,
177− runCatchingCancellable { conn.api.agents() }
177+ error = _agentsError,
178− .onFailure { Log.w("ChatViewModel", "Failed to reload agent catalog", it); _agentsError.value = true }
178+ fetch = { it.agents() },
179− .getOrNull()?.let { _agents.value = it }
179+ onSuccess = { _agents.value = it },
180− _agentsLoading.value = false
180+ )
181− }
182 }
181 }
183
182
184 fun reloadCommands() {
183 fun reloadCommands() {
185 val conn = connection ?: return
184 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+ ) {
186 viewModelScope.launch {
208 viewModelScope.launch {
187 conn.api.invalidateCache()
209 conn.api.invalidateCache()
188− _commandsLoading.value = true
210+ loading.value = true
189− _commandsError.value = false
211+ error.value = false
190− runCatchingCancellable { conn.api.commands() }
212+ runCatchingCancellable { fetch(conn.api) }
191− .onFailure { Log.w("ChatViewModel", "Failed to reload command catalog", it); _commandsError.value = true }
213+ .onFailure { Log.w("ChatViewModel", "Failed to reload $tag", it); error.value = true }
192− .getOrNull()?.let { _commands.value = it }
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)
193− _commandsLoading.value = false
241+ loading.value = false
242+ }
194 }
243 }
195 }
244 }
196
245
@@ -201,7 +250,7 @@ class ChatViewModel(
201 init {
250 init {
202 // Debounce draft persistence so we don't write to disk on every keystroke.
251 // Debounce draft persistence so we don't write to disk on every keystroke.
203 viewModelScope.launch {
252 viewModelScope.launch {
204− _draft.drop(1).debounce(500).collect { text ->
253+ _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
205 runCatchingCancellable { container.draftStore.set(sessionId, text) }
254 runCatchingCancellable { container.draftStore.set(sessionId, text) }
206 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
255 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
207 }
256 }
@@ -241,45 +290,36 @@ class ChatViewModel(
241 }
290 }
242 // Load the model catalog; preselect the server default. Failure is non-fatal —
291 // Load the model catalog; preselect the server default. Failure is non-fatal —
243 // sending with no model just uses the server's default agent/model.
292 // sending with no model just uses the server's default agent/model.
244− viewModelScope.launch {
293+ observeCatalog(
245− container.activeConnection.collectLatest { conn ->
294+ tag = "model catalog",
246− if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; _modelsError.value = false; return@collectLatest }
295+ loading = _modelsLoading,
247− _modelsLoading.value = true
296+ error = _modelsError,
248− _modelsError.value = false
297+ fetch = { it.providers() },
249− runCatchingCancellable { conn.api.providers() }
298+ onSuccess = { resp ->
250− .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it); _modelsError.value = true }
251− .getOrNull()?.let { resp ->
252− val options = resp.toOptions()
299+ val options = resp.toOptions()
253− _models.value = options
300+ _models.value = options
254− _selectedModel.value = resp.defaultOption(options)
301+ _selectedModel.value = resp.defaultOption(options)
255− }
302+ },
256− _modelsLoading.value = false
303+ onNull = { _models.value = emptyList(); _selectedModel.value = null },
257− }
304+ )
258− }
259 // Load the agent catalog (non-fatal).
305 // Load the agent catalog (non-fatal).
260− viewModelScope.launch {
306+ observeCatalog(
261− container.activeConnection.collectLatest { conn ->
307+ tag = "agent catalog",
262− if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; _agentsError.value = false; return@collectLatest }
308+ loading = _agentsLoading,
263− _agentsLoading.value = true
309+ error = _agentsError,
264− _agentsError.value = false
310+ fetch = { it.agents() },
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 }
311+ onSuccess = { _agents.value = it },
268− _agentsLoading.value = false
312+ onNull = { _agents.value = emptyList() },
269− }
313+ )
270− }
271 // Load the command catalog (non-fatal).
314 // Load the command catalog (non-fatal).
272− viewModelScope.launch {
315+ observeCatalog(
273− container.activeConnection.collectLatest { conn ->
316+ tag = "command catalog",
274− if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; _commandsError.value = false; return@collectLatest }
317+ loading = _commandsLoading,
275− _commandsLoading.value = true
318+ error = _commandsError,
276− _commandsError.value = false
319+ fetch = { it.commands() },
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 }
320+ onSuccess = { _commands.value = it },
280− _commandsLoading.value = false
321+ onNull = { _commands.value = emptyList() },
281− }
322+ )
282− }
283 // Resolve the human-readable session title for the app bar (non-fatal).
323 // Resolve the human-readable session title for the app bar (non-fatal).
284 viewModelScope.launch {
324 viewModelScope.launch {
285 container.activeConnection.collectLatest { conn ->
325 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
20import soy.iko.opencode.data.model.MessageWithParts
20import soy.iko.opencode.data.model.MessageWithParts
21import soy.iko.opencode.data.model.Tokens
21import soy.iko.opencode.data.model.Tokens
22import soy.iko.opencode.data.model.UserMessage
22import soy.iko.opencode.data.model.UserMessage
23+import soy.iko.opencode.data.network.NetworkConfig
23import soy.iko.opencode.ui.components.ImageLoadContext
24import soy.iko.opencode.ui.components.ImageLoadContext
24import soy.iko.opencode.ui.components.rememberRelativeTime
25import soy.iko.opencode.ui.components.rememberRelativeTime
25
26
@@ -42,7 +43,7 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
42 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
43 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
43 Column(
44 Column(
44 modifier = Modifier
45 modifier = Modifier
45− .fillMaxWidth(0.85f)
46+ .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
46 .clip(RoundedCornerShape(16.dp))
47 .clip(RoundedCornerShape(16.dp))
47 .background(MaterialTheme.colorScheme.primaryContainer)
48 .background(MaterialTheme.colorScheme.primaryContainer)
48 .padding(12.dp),
49 .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
43import org.intellij.markdown.ast.ASTNode
43import org.intellij.markdown.ast.ASTNode
44import org.intellij.markdown.ast.getTextInNode
44import org.intellij.markdown.ast.getTextInNode
45import soy.iko.opencode.R
45import soy.iko.opencode.R
46+import soy.iko.opencode.data.network.NetworkConfig
46
47
47/**
48/**
48 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
49 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
@@ -67,13 +68,15 @@ fun MarkdownText(
67 // into a single markdown re-parse per frame instead of one per token. The delay
68 // into a single markdown re-parse per frame instead of one per token. The delay
68 // is only applied when the content is growing incrementally (streaming) — a
69 // is only applied when the content is growing incrementally (streaming) — a
69 // content switch or initial render proceeds immediately.
70 // content switch or initial render proceeds immediately.
71+ // Keyed to a content-prefix so switching to a different message resets
72+ // immediately instead of showing the old message for one frame.
70− var renderedContent by remember { mutableStateOf(markdown) }
73+ var renderedContent by remember(markdown.take(32)) { mutableStateOf(markdown) }
71 LaunchedEffect(markdown) {
74 LaunchedEffect(markdown) {
72 if (renderedContent.isNotEmpty() &&
75 if (renderedContent.isNotEmpty() &&
73 markdown.startsWith(renderedContent) &&
76 markdown.startsWith(renderedContent) &&
74 markdown != renderedContent
77 markdown != renderedContent
75 ) {
78 ) {
76− delay(16)
79+ delay(NetworkConfig.streamingThrottleMs)
77 }
80 }
78 renderedContent = markdown
81 renderedContent = markdown
79 }
82 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+3−2
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
4import androidx.lifecycle.ViewModel
4import androidx.lifecycle.ViewModel
5import androidx.lifecycle.viewModelScope
5import androidx.lifecycle.viewModelScope
6import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.data.model.ServerProfile
7+import soy.iko.opencode.data.network.NetworkConfig
7import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.di.AppContainer
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.StateFlow
@@ -50,7 +51,7 @@ class ServerEditViewModel(
50 if (profileId != null) {
51 if (profileId != null) {
51 // Wait for a non-empty profile list before searching, so DataStore
52 // Wait for a non-empty profile list before searching, so DataStore
52 // emitting an empty list during loading doesn't cause a false timeout.
53 // emitting an empty list during loading doesn't cause a false timeout.
53− val existing = withTimeoutOrNull(5_000) {
54+ val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
54 container.profileStore.profiles
55 container.profileStore.profiles
55 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
56 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
56 .firstOrNull { it.id == profileId }
57 .firstOrNull { it.id == profileId }
@@ -90,7 +91,7 @@ class ServerEditViewModel(
90 viewModelScope.launch {
91 viewModelScope.launch {
91 val result = runCatchingCancellable {
92 val result = runCatchingCancellable {
92 val existingLastUsed = if (s.id != null) {
93 val existingLastUsed = if (s.id != null) {
93− withTimeoutOrNull(5_000) {
94+ withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
94 container.profileStore.profiles.first()
95 container.profileStore.profiles.first()
95 .firstOrNull { it.id == s.id }?.lastUsed
96 .firstOrNull { it.id == s.id }?.lastUsed
96 } ?: 0L
97 } ?: 0L
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+2−1
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.server
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.ServerProfile
5import soy.iko.opencode.data.model.ServerProfile
6+import soy.iko.opencode.data.network.NetworkConfig
6import soy.iko.opencode.di.AppContainer
7import soy.iko.opencode.di.AppContainer
7import soy.iko.opencode.R
8import soy.iko.opencode.R
8import soy.iko.opencode.util.runCatchingCancellable
9import soy.iko.opencode.util.runCatchingCancellable
@@ -18,7 +19,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
18 val profiles: StateFlow<List<ServerProfile>> =
19 val profiles: StateFlow<List<ServerProfile>> =
19 container.profileStore.profiles.stateIn(
20 container.profileStore.profiles.stateIn(
20 scope = viewModelScope,
21 scope = viewModelScope,
21− started = SharingStarted.WhileSubscribed(5_000),
22+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
22 initialValue = emptyList(),
23 initialValue = emptyList(),
23 )
24 )
24
25
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+2−2
@@ -58,7 +58,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
58 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
58 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
59 .stateIn(
59 .stateIn(
60 scope = viewModelScope,
60 scope = viewModelScope,
61− started = SharingStarted.WhileSubscribed(5_000),
61+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
62 initialValue = EventStreamClient.ConnectionState.Disconnected,
62 initialValue = EventStreamClient.ConnectionState.Disconnected,
63 )
63 )
64
64
@@ -68,7 +68,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
68 val profiles: StateFlow<List<ServerProfile>> =
68 val profiles: StateFlow<List<ServerProfile>> =
69 container.profileStore.profiles.stateIn(
69 container.profileStore.profiles.stateIn(
70 scope = viewModelScope,
70 scope = viewModelScope,
71− started = SharingStarted.WhileSubscribed(5_000),
71+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
72 initialValue = emptyList(),
72 initialValue = emptyList(),
73 )
73 )
74
74
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+3−2
@@ -25,6 +25,7 @@ import androidx.compose.ui.res.stringResource
25import androidx.compose.ui.unit.dp
25import androidx.compose.ui.unit.dp
26import androidx.lifecycle.compose.collectAsStateWithLifecycle
26import androidx.lifecycle.compose.collectAsStateWithLifecycle
27import soy.iko.opencode.R
27import soy.iko.opencode.R
28+import soy.iko.opencode.data.network.NetworkConfig
28import soy.iko.opencode.di.AppContainer
29import soy.iko.opencode.di.AppContainer
29import soy.iko.opencode.ui.chat.ChatScreen
30import soy.iko.opencode.ui.chat.ChatScreen
30
31
@@ -74,7 +75,7 @@ fun TwoPaneSessionChat(
74 Row(modifier = modifier.fillMaxSize()) {
75 Row(modifier = modifier.fillMaxSize()) {
75 Box(
76 Box(
76 modifier = Modifier
77 modifier = Modifier
77− .weight(0.38f)
78+ .weight(NetworkConfig.twoPaneLeftWeight)
78 .widthIn(max = 460.dp),
79 .widthIn(max = 460.dp),
79 ) {
80 ) {
80 SessionListScreen(
81 SessionListScreen(
@@ -88,7 +89,7 @@ fun TwoPaneSessionChat(
88 )
89 )
89 }
90 }
90
91
91− Box(modifier = Modifier.weight(0.62f).fillMaxSize()) {
92+ Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
92 val sessionId = selected
93 val sessionId = selected
93 if (sessionId == null) {
94 if (sessionId == null) {
94 EmptyDetail()
95 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 {
12 Result.success(block())
12 Result.success(block())
13} catch (e: CancellationException) {
13} catch (e: CancellationException) {
14 throw e
14 throw e
15−} catch (e: Throwable) {
15+} catch (e: Exception) {
16 Result.failure(e)
16 Result.failure(e)
17}
17}