git.iko.soy/opencode-android
on master help sign in
Fix 9 bugs: CancellationException swallowing in shutdown, defeated Crossfade animations on 5 screens, rotation state loss in ChatScreen, search effect re-keying on every token, SSE reconnect counter drift, Usage time-range race, unbounded tombstone growth, and stale-connection send on server switch
commit79564d6d393b39929b5761b99b68fcfe7e7bc23a authoriko <mail@iko.soy> dateTue 7 Jul 2026, 09:15 (3 months ago) parentc9fdd78
12 files changed+184−91
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+7 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+7 app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+10−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+10−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+25−7 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+11 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+12−9 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+33−28 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+13−10 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+7−4 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+29−24 app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+20−6
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+7
@@ -312,6 +312,13 @@ open class EventStreamClient(
312312 _state.value = ConnectionState.Disconnected
313313 throw c
314314 }
315+ // Reset the reconnect-attempt counter: the user explicitly triggered this
316+ // reconnect (fixed credentials, manual refresh, ...), so it's a fresh
317+ // attempt — not a continuation of the pre-failure outage. Without this,
318+ // firstAttempt is already false and line 196 increments the counter on
319+ // every manual reconnect, growing "Reconnecting (attempt N)" unboundedly
320+ // across repeated 4xx → fix → retry cycles.
321+ _reconnectAttempts.value = 0
315322 continue
316323 } else if (isActive) {
317324 Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+7
@@ -112,6 +112,13 @@ object NetworkConfig {
112112 * instant/offline first paint; the network corrects it on the next open. */
113113 const val messageCacheWriteThrottleMs = 2_000L
114114
115+ /** Max entries in MessageCacheStore's tombstone set. Tombstones only need to survive long
116+ * enough to block a racing teardown flush (which resolves within seconds of deletion), so
117+ * entries from long-ago deletions are useless. Capping prevents unbounded growth across
118+ * many session deletions over the app's lifetime; when exceeded the set is cleared wholesale
119+ * (coarse but safe — no racing flush can still be alive for a session deleted sessions ago). */
120+ const val maxMessageCacheTombstones = 128
121+
115122 /** Grace period before a deferred session delete actually fires, during which an
116123 * Undo snackbar lets the user cancel it. */
117124 const val undoDeleteDelayMs = 5_000L
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+10−1
@@ -64,7 +64,8 @@ open class MessageCacheStore private constructor(
6464 * "$profileId/$sessionId" — a bare sessionId would suppress a *different* server's
6565 * saves if two servers share a colliding session id (the very case the per-profile
6666 * namespacing at [profileDir] exists to defend against). Cleared by [load] so
67− * re-opening a session re-enables saving. Bounded by distinct deleted sessions. */
67+ * re-opening a session re-enables saving. Capped at [NetworkConfig.maxMessageCacheTombstones]
68+ * entries (see [remove]) to prevent unbounded growth across many deletions. */
6869 private val tombstones: MutableSet<String> =
6970 java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
7071
@@ -128,6 +129,14 @@ open class MessageCacheStore private constructor(
128129 // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
129130 // instead of re-creating the file we're about to delete.
130131 tombstones.add(tombstoneKey(profileId, sessionId))
132+ // Cap the tombstone set so it doesn't grow unbounded across many session deletions.
133+ // Tombstones only protect against a racing teardown flush (which resolves within
134+ // seconds), so stale entries are useless. Clearing wholesale is safe because no
135+ // racing flush can still be alive for a session deleted long ago.
136+ if (tombstones.size > soy.iko.opencode.data.network.NetworkConfig.maxMessageCacheTombstones) {
137+ tombstones.clear()
138+ tombstones.add(tombstoneKey(profileId, sessionId))
139+ }
131140 withContext(Dispatchers.IO) {
132141 runCatchingCancellable {
133142 val name = idRegex.replace(sessionId, "_") + ".json"
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+10−2
@@ -665,7 +665,12 @@ open class AppContainer private constructor(
665665 kotlinx.coroutines.runBlocking {
666666 withTimeoutOrNull(2_000) {
667667 connectionMutex.withLock {
668− runCatching { _activeConnection.value?.close() }
668+ // runCatchingCancellable (not plain runCatching) per the repo convention:
669+ // close() is a suspend function, and withTimeoutOrNull above can cancel
670+ // this block mid-close — plain runCatching would swallow the
671+ // CancellationException, leaving the connection half-closed and falsely
672+ // reporting success via `closed = true`.
673+ runCatchingCancellable { _activeConnection.value?.close() }
669674 _activeConnection.value = null
670675 closed = true
671676 }
@@ -681,7 +686,10 @@ open class AppContainer private constructor(
681686 // stuck connection's scope directly. close() suspends (scopeJob.join), so
682687 // wrap in a bounded runBlocking that can't ANR — at this point the app is
683688 // shutting down, so reaping is best-effort.
684− runCatching {
689+ // runCatchingCancellable: withTimeoutOrNull can cancel stuck.close() mid-flight,
690+ // and plain runCatching would swallow the CancellationException — same invariant
691+ // as the locked close above.
692+ runCatchingCancellable {
685693 kotlinx.coroutines.runBlocking {
686694 kotlinx.coroutines.withTimeoutOrNull(1_000) { stuck.close() }
687695 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+25−7
@@ -1066,7 +1066,10 @@ fun ChatScreen(
10661066 // New-content signal for the jump-to-latest FAB: counts how many new items arrived
10671067 // while the user was scrolled away from the bottom, cleared once they return. A count
10681068 // (vs. a bare dot) tells the user how much they missed at a glance.
1069− var newContentCount by remember { mutableIntStateOf(0) }
1069+ // rememberSaveable (not plain remember) so the badge count survives rotation: plain
1070+ // remember resets to 0 on recreation, hiding the "new content" signal even though new
1071+ // messages are still arriving. Int is Bundle-savable.
1072+ var newContentCount by rememberSaveable { mutableIntStateOf(0) }
10701073
10711074 val isPinnedToBottom by remember {
10721075 derivedStateOf {
@@ -1116,13 +1119,24 @@ fun ChatScreen(
11161119 // Scroll the focused search match into view whenever the user steps through results with
11171120 // the up/down arrows. Resolves the filtered message to its slot in listItems (which
11181121 // includes date separators) and animates there.
1119− LaunchedEffect(searchPos, searchQuery, listItems) {
1122+ //
1123+ // Keyed ONLY on searchPos and searchQuery (not listItems): a bare listItems key re-keys
1124+ // on every streamed token (the list is rebuilt per snapshot), which re-fires this effect
1125+ // ~20×/sec during an active stream — re-scrolling to the focused match on every token
1126+ // and making it impossible to browse other matches with the up/down arrows while content
1127+ // is streaming. snapshotFlow (reading currentListItems, a rememberUpdatedState) waits for
1128+ // the target to appear without re-keying the effect on every list change, mirroring the
1129+ // focusedMessageId pattern below.
1130+ LaunchedEffect(searchPos, searchQuery) {
11201131 if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
11211132 val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
1122− val index = listItems.indexOfFirst {
1123− it is MessageListItem.Message && it.message.info.id == target.info.id
1124− }
1125− if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
1133+ val targetId = target.info.id
1134+ val index = snapshotFlow {
1135+ currentListItems.indexOfFirst {
1136+ it is MessageListItem.Message && it.message.info.id == targetId
1137+ }
1138+ }.first { it >= 0 }
1139+ runCatchingCancellable { listState.animateScrollToItem(index) }
11261140 }
11271141
11281142 // Global-search deep link: scroll to and briefly highlight the matched message. The
@@ -1136,7 +1150,11 @@ fun ChatScreen(
11361150 // actively-streaming chat unreadable. snapshotFlow waits for the message to appear in the
11371151 // list (it may not be present on the first composition while messages load) without
11381152 // re-keying the effect on every list change.
1139− var focusedMessageId by remember(focusMessageId) { mutableStateOf(focusMessageId) }
1153+ // rememberSaveable (not plain remember) so the cleared null survives rotation:
1154+ // plain remember keyed on focusMessageId re-seeds the non-null nav arg on every
1155+ // recreation, re-firing this effect and yanking scroll back to the focus after the
1156+ // user already dismissed the highlight. String? is Bundle-savable.
1157+ var focusedMessageId by rememberSaveable(focusMessageId) { mutableStateOf(focusMessageId) }
11401158 val currentListItemsForFocus by rememberUpdatedState(listItems)
11411159 LaunchedEffect(focusedMessageId) {
11421160 val focus = focusedMessageId ?: return@LaunchedEffect
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+11
@@ -1188,6 +1188,17 @@ class ChatViewModel(
11881188 // the launch without wiping the persisted follow-up — the follow-up is then
11891189 // recovered on the next session open instead of being silently lost.
11901190 if (queuedFollowUpToClear != null) setQueuedFollowUp(null)
1191+ // Re-check the active connection: send() captured `conn` synchronously above, but a
1192+ // server switch between send() returning and this coroutine starting would send the
1193+ // prompt to the OLD (closed) server. Route to the outbox instead so the message is
1194+ // delivered to the correct server on reconnect, rather than failing against a stale
1195+ // connection. Reset _running (set by beginRun above) since no SSE streaming will
1196+ // follow an outbox enqueue — without this the run indicator would be stuck on.
1197+ if (container.activeConnection.value !== conn) {
1198+ enqueueOffline(trimmed, attachments)
1199+ _running.value = false
1200+ return@launch
1201+ }
11911202 runCatchingCancellable {
11921203 conn.repository.sendPrompt(
11931204 sessionId,
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+12−9
@@ -236,25 +236,28 @@ fun FileBrowserScreen(
236236 val loadingLabel = stringResource(R.string.loading)
237237 // Crossfade between content states so transitions read as a smooth fade
238238 // instead of an instant snap. Matches the session list's Crossfade pattern;
239− // reduced motion is honored by Crossfade's default spec.
239+ // reduced motion is honored by Crossfade's default spec. The content lambda
240+ // branches on its target-state parameter (not the captured `state`) so the
241+ // outgoing layer keeps rendering the OLD state type while it fades out —
242+ // reading `state` directly would recompose both layers to the latest content
243+ // and defeat the crossfade into an instant snap.
240244 val stateKey = fileBrowserStateKey(state)
241− @Suppress("UnusedCrossfadeTargetStateParameter")
242245 Crossfade(
243246 targetState = stateKey,
244247 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
245248 label = "file_browser_state",
246− ) {
247− when {
249+ ) { key ->
250+ when (key) {
248251 // Skeleton loader for the initial directory load. An in-flight search
249252 // keeps the previous results visible (a slim top bar below shows the
250253 // progress) instead of blanking the list on every keystroke.
251− state.loading -> Column(
254+ "loading" -> Column(
252255 modifier = Modifier.fillMaxSize().padding(16.dp),
253256 verticalArrangement = Arrangement.spacedBy(12.dp),
254257 ) {
255258 repeat(6) { SkeletonRow() }
256259 }
257− state.error != null -> {
260+ "error" -> {
258261 // Detect a permission-denied error (the server returns a 403/EACCES
259262 // when the workspace directory isn't readable) and show a specific
260263 // message + icon instead of the generic error, so the user
@@ -297,17 +300,17 @@ fun FileBrowserScreen(
297300 }
298301 }
299302 }
300− state.mode == SearchMode.TEXT ->
303+ "text" ->
301304 TextResults(
302305 state.textResults,
303306 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
304307 ) { path, line -> onOpenFile(path, line) }
305− state.mode == SearchMode.SYMBOL ->
308+ "symbol" ->
306309 SymbolResults(
307310 state.symbolResults,
308311 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
309312 ) { path, line -> onOpenFile(path, line) }
310− state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
313+ "search" -> SearchResults(state.results) { onOpenFile(it, null) }
311314 else -> DirectoryListing(
312315 container = container,
313316 state = state,
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+33−28
@@ -142,54 +142,59 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
142142 ConnectionBannerFor(container)
143143 // Crossfade between content states so transitions read as a smooth fade instead
144144 // of an instant snap. Matches the session list's Crossfade pattern; reduced
145− // motion is honored by Crossfade's default spec.
145+ // motion is honored by Crossfade's default spec. The content lambda branches
146+ // on its target-state parameter (not the captured `state`) so the outgoing
147+ // layer keeps rendering the OLD state type while it fades out — reading `state`
148+ // directly would recompose both layers to the latest content and defeat the
149+ // crossfade into an instant snap.
146150 val stateKey = state::class.simpleName
147− @Suppress("UnusedCrossfadeTargetStateParameter")
148151 Crossfade(
149152 targetState = stateKey,
150153 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
151154 label = "mcp_state",
152− ) {
153− when (val s = state) {
154− is McpViewModel.State.Loading -> Column(
155+ ) { key ->
156+ when (key) {
157+ "Loading" -> Column(
155158 modifier = Modifier.fillMaxSize().padding(16.dp),
156159 verticalArrangement = Arrangement.spacedBy(12.dp),
157160 ) {
158161 repeat(6) { SkeletonRow() }
159162 }
160− is McpViewModel.State.Disconnected -> EmptyState(
163+ "Disconnected" -> EmptyState(
161164 icon = Icons.Filled.CloudOff,
162165 title = stringResource(R.string.not_connected),
163166 modifier = Modifier.align(Alignment.Center),
164167 )
165− is McpViewModel.State.Error -> EmptyState(
168+ "Error" -> EmptyState(
166169 icon = Icons.Filled.ErrorOutline,
167170 title = stringResource(R.string.mcp_failed),
168− description = s.message,
171+ description = (state as? McpViewModel.State.Error)?.message,
169172 modifier = Modifier.align(Alignment.Center),
170173 actionLabel = stringResource(R.string.retry),
171174 onAction = { vm.load() },
172175 )
173− is McpViewModel.State.Ready ->
174− if (s.servers.isEmpty()) {
175− EmptyState(
176− icon = Icons.Filled.Hub,
177− title = stringResource(R.string.mcp_empty),
178− modifier = Modifier.align(Alignment.Center),
179− actionIcon = Icons.Filled.Add,
180− actionLabel = stringResource(R.string.mcp_add_server),
181− onAction = { showAddDialog = true },
182− )
183− } else {
184− LazyColumn(
185− modifier = Modifier.fillMaxWidth(),
186− contentPadding = PaddingValues(
187− start = 16.dp, end = 16.dp, top = 16.dp, bottom = soy.iko.opencode.data.network.NetworkConfig.listFabInsetDp.dp,
188− ),
189− verticalArrangement = Arrangement.spacedBy(12.dp),
190− ) {
191− items(s.servers, key = { it.name }) {
192− McpServerCard(it, modifier = Modifier.then(reducedMotionAnimateItem()))
176+ "Ready" ->
177+ (state as? McpViewModel.State.Ready)?.let { s ->
178+ if (s.servers.isEmpty()) {
179+ EmptyState(
180+ icon = Icons.Filled.Hub,
181+ title = stringResource(R.string.mcp_empty),
182+ modifier = Modifier.align(Alignment.Center),
183+ actionIcon = Icons.Filled.Add,
184+ actionLabel = stringResource(R.string.mcp_add_server),
185+ onAction = { showAddDialog = true },
186+ )
187+ } else {
188+ LazyColumn(
189+ modifier = Modifier.fillMaxWidth(),
190+ contentPadding = PaddingValues(
191+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = soy.iko.opencode.data.network.NetworkConfig.listFabInsetDp.dp,
192+ ),
193+ verticalArrangement = Arrangement.spacedBy(12.dp),
194+ ) {
195+ items(s.servers, key = { it.name }) {
196+ McpServerCard(it, modifier = Modifier.then(reducedMotionAnimateItem()))
197+ }
193198 }
194199 }
195200 }
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+13−10
@@ -163,16 +163,19 @@ fun GlobalSearchScreen(
163163 val showResultsWhileSearching = state.searching && state.hasSearched && state.results.isNotEmpty()
164164 // Crossfade between content states so transitions read as a smooth fade instead
165165 // of an instant snap. Matches the session list's Crossfade pattern; reduced
166− // motion is honored by Crossfade's default spec.
166+ // motion is honored by Crossfade's default spec. The content lambda branches
167+ // on its target-state parameter (not the captured `state`) so the outgoing
168+ // layer keeps rendering the OLD state type while it fades out — reading `state`
169+ // directly would recompose both layers to the latest content and defeat the
170+ // crossfade into an instant snap.
167171 val stateKey = globalSearchStateKey(state)
168− @Suppress("UnusedCrossfadeTargetStateParameter")
169172 Crossfade(
170173 targetState = stateKey,
171174 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
172175 label = "global_search_state",
173− ) {
174− when {
175− state.searching && !showResultsWhileSearching -> Column(
176+ ) { key ->
177+ when (key) {
178+ "searching" -> Column(
176179 modifier = Modifier.align(Alignment.Center),
177180 horizontalAlignment = Alignment.CenterHorizontally,
178181 ) {
@@ -189,14 +192,14 @@ fun GlobalSearchScreen(
189192 )
190193 }
191194 }
192− state.error != null -> EmptyState(
195+ "error" -> EmptyState(
193196 icon = Icons.Filled.ErrorOutline,
194197 title = state.error ?: "",
195198 modifier = Modifier.align(Alignment.Center),
196199 actionLabel = stringResource(R.string.retry),
197200 onAction = vm::retry,
198201 )
199− state.hasSearched && state.results.isEmpty() -> EmptyState(
202+ "no_matches" -> EmptyState(
200203 icon = Icons.Filled.SearchOff,
201204 title = stringResource(R.string.search_no_matches),
202205 modifier = Modifier.align(Alignment.Center),
@@ -204,14 +207,14 @@ fun GlobalSearchScreen(
204207 // Typed something but below the min length (no search run yet): nudge to keep
205208 // typing rather than reverting to the untouched start state, which reads as if
206209 // the field were empty.
207− state.query.isNotBlank() && !state.hasSearched -> EmptyState(
210+ "keep_typing" -> EmptyState(
208211 icon = Icons.Filled.Search,
209212 title = stringResource(R.string.search_keep_typing),
210213 modifier = Modifier.align(Alignment.Center),
211214 )
212215 // Empty query + persisted history: surface suggestions so a returning user can
213216 // re-run a prior search in one tap instead of retyping it.
214− state.query.isBlank() && state.history.isNotEmpty() -> HistorySuggestions(
217+ "history" -> HistorySuggestions(
215218 history = state.history,
216219 onPick = { query ->
217220 vm.setQuery(query)
@@ -220,7 +223,7 @@ fun GlobalSearchScreen(
220223 onClear = vm::clearHistory,
221224 modifier = Modifier.fillMaxSize(),
222225 )
223− state.results.isEmpty() -> EmptyState(
226+ "start" -> EmptyState(
224227 icon = Icons.Filled.Search,
225228 title = stringResource(R.string.search_all_start),
226229 description = stringResource(R.string.search_all_start_hint),
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+7−4
@@ -282,15 +282,18 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
282282 )
283283 // Crossfade between empty and list states so the transition reads as a smooth fade
284284 // instead of an instant snap. Matches the session list's Crossfade pattern; reduced
285− // motion is honored by Crossfade's default spec.
285+ // motion is honored by Crossfade's default spec. The content lambda branches on its
286+ // target-state parameter (not the captured `reports`) so the outgoing layer keeps
287+ // rendering the OLD state type while it fades out — reading `reports` directly would
288+ // recompose both layers to the latest content and defeat the crossfade into an
289+ // instant snap.
286290 val stateKey = if (reports.isEmpty()) "empty" else "list"
287− @Suppress("UnusedCrossfadeTargetStateParameter")
288291 Crossfade(
289292 targetState = stateKey,
290293 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
291294 label = "diagnostics_state",
292− ) {
293− if (reports.isEmpty()) {
295+ ) { key ->
296+ if (key == "empty") {
294297 EmptyState(
295298 icon = Icons.Filled.BugReport,
296299 title = stringResource(R.string.no_crash_reports),
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+29−24
@@ -108,50 +108,55 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
108108 ConnectionBannerFor(container)
109109 // Crossfade between content states so transitions read as a smooth fade instead
110110 // of an instant snap. Matches the session list's Crossfade pattern; reduced
111− // motion is honored by Crossfade's default spec.
111+ // motion is honored by Crossfade's default spec. The content lambda branches
112+ // on its target-state parameter (not the captured `state`) so the outgoing
113+ // layer keeps rendering the OLD state type while it fades out — reading `state`
114+ // directly would recompose both layers to the latest content and defeat the
115+ // crossfade into an instant snap.
112116 val stateKey = state::class.simpleName
113− @Suppress("UnusedCrossfadeTargetStateParameter")
114117 Crossfade(
115118 targetState = stateKey,
116119 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
117120 label = "usage_state",
118− ) {
119− when (val s = state) {
120− is UsageViewModel.State.Loading -> Column(
121+ ) { key ->
122+ when (key) {
123+ "Loading" -> Column(
121124 modifier = Modifier.fillMaxSize().padding(16.dp),
122125 verticalArrangement = Arrangement.spacedBy(12.dp),
123126 ) {
124127 repeat(6) { SkeletonRow() }
125128 }
126− is UsageViewModel.State.Disconnected -> EmptyState(
129+ "Disconnected" -> EmptyState(
127130 icon = Icons.Filled.CloudOff,
128131 title = stringResource(R.string.not_connected),
129132 modifier = Modifier.align(Alignment.Center),
130133 )
131− is UsageViewModel.State.Error -> EmptyState(
134+ "Error" -> EmptyState(
132135 icon = Icons.Filled.ErrorOutline,
133136 title = stringResource(R.string.usage_failed),
134− description = s.message,
137+ description = (state as? UsageViewModel.State.Error)?.message,
135138 modifier = Modifier.align(Alignment.Center),
136139 actionLabel = stringResource(R.string.retry),
137140 onAction = { vm.load() },
138141 )
139− is UsageViewModel.State.Ready ->
140− if (s.report.isEmpty) {
141− EmptyState(
142− icon = Icons.Filled.QueryStats,
143− title = stringResource(R.string.usage_empty),
144− description = stringResource(R.string.usage_empty_desc),
145− modifier = Modifier.align(Alignment.Center),
146− )
147− } else {
148− val timeRange by vm.timeRange.collectAsStateWithLifecycle()
149− UsageContent(
150− report = s.report,
151− onOpenSession = onOpenSession,
152− timeRange = timeRange,
153− onTimeRangeChange = vm::setTimeRange,
154− )
142+ "Ready" ->
143+ (state as? UsageViewModel.State.Ready)?.let { s ->
144+ if (s.report.isEmpty) {
145+ EmptyState(
146+ icon = Icons.Filled.QueryStats,
147+ title = stringResource(R.string.usage_empty),
148+ description = stringResource(R.string.usage_empty_desc),
149+ modifier = Modifier.align(Alignment.Center),
150+ )
151+ } else {
152+ val timeRange by vm.timeRange.collectAsStateWithLifecycle()
153+ UsageContent(
154+ report = s.report,
155+ onOpenSession = onOpenSession,
156+ timeRange = timeRange,
157+ onTimeRangeChange = vm::setTimeRange,
158+ )
159+ }
155160 }
156161 }
157162 }
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+20−6
@@ -102,7 +102,12 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
102102 // spinner on the first load.
103103 if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
104104 try {
105− runCatchingCancellable {
105+ // Fetch the raw per-session data first; the slow part is the network I/O, not the
106+ // aggregation. The cutoff is read AFTER the fetch completes (not before) so a time-
107+ // range change that arrives mid-fetch is honored: reaggregate() returns early while
108+ // state is Loading, so without this the report would be computed with the stale cutoff
109+ // and the user's new selection wouldn't apply until the next manual reload.
110+ val fetched = runCatchingCancellable {
106111 val sessions = conn.repository.listSessions()
107112 // Cap concurrent message fetches so a large history doesn't open dozens of
108113 // sockets at once; a per-session fetch failure yields an empty list rather
@@ -111,7 +116,7 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
111116 // coroutineScope so the per-session fetches are children of THIS load: when
112117 // collectLatest cancels a stale load (connection changed / manual reload), the
113118 // in-flight fetches are cancelled with it instead of leaking.
114− val perSession = coroutineScope {
119+ coroutineScope {
115120 sessions.map { session ->
116121 async {
117122 limiter.withPermit {
@@ -127,12 +132,21 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
127132 }
128133 }.awaitAll()
129134 }
130− cachedInputs = perSession
135+ }
136+ // On failure, set Error; the finally block resets _refreshing. On success, cache
137+ // the inputs and aggregate under the LATEST time-range cutoff (read after the fetch
138+ // so a range change that arrived mid-fetch is honored — reaggregate() returns early
139+ // while state is Loading, so without re-reading here the report would use the stale
140+ // cutoff and the user's new selection wouldn't apply until the next manual reload).
141+ if (fetched.isFailure) {
142+ _state.value = State.Error(safeExceptionSummary(fetched.exceptionOrNull()!!))
143+ } else {
144+ val inputs = fetched.getOrThrow()
145+ cachedInputs = inputs
131146 val cutoff = _timeRange.value.cutoffMs()
132− withContext(Dispatchers.Default) { aggregateUsage(perSession, cutoff) }
147+ val report = withContext(Dispatchers.Default) { aggregateUsage(inputs, cutoff) }
148+ _state.value = State.Ready(report)
133149 }
134− .onSuccess { _state.value = State.Ready(it) }
135− .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
136150 } finally {
137151 // Reset in finally so a collectLatest cancellation (which rethrows
138152 // CancellationException out of runCatchingCancellable, skipping the lines above)