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
79564d6iko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+7
@@ -312,6 +312,13 @@ open class EventStreamClient(
312 _state.value = ConnectionState.Disconnected
312 _state.value = ConnectionState.Disconnected
313 throw c
313 throw c
314 }
314 }
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
315 continue
322 continue
316 } else if (isActive) {
323 } else if (isActive) {
317 Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
324 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 {
112 * instant/offline first paint; the network corrects it on the next open. */
112 * instant/offline first paint; the network corrects it on the next open. */
113 const val messageCacheWriteThrottleMs = 2_000L
113 const val messageCacheWriteThrottleMs = 2_000L
114
114
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+
115 /** Grace period before a deferred session delete actually fires, during which an
122 /** Grace period before a deferred session delete actually fires, during which an
116 * Undo snackbar lets the user cancel it. */
123 * Undo snackbar lets the user cancel it. */
117 const val undoDeleteDelayMs = 5_000L
124 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(
64 * "$profileId/$sessionId" — a bare sessionId would suppress a *different* server's
64 * "$profileId/$sessionId" — a bare sessionId would suppress a *different* server's
65 * saves if two servers share a colliding session id (the very case the per-profile
65 * saves if two servers share a colliding session id (the very case the per-profile
66 * namespacing at [profileDir] exists to defend against). Cleared by [load] so
66 * 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. */
68 private val tombstones: MutableSet<String> =
69 private val tombstones: MutableSet<String> =
69 java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
70 java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
70
71
@@ -128,6 +129,14 @@ open class MessageCacheStore private constructor(
128 // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
129 // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
129 // instead of re-creating the file we're about to delete.
130 // instead of re-creating the file we're about to delete.
130 tombstones.add(tombstoneKey(profileId, sessionId))
131 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+ }
131 withContext(Dispatchers.IO) {
140 withContext(Dispatchers.IO) {
132 runCatchingCancellable {
141 runCatchingCancellable {
133 val name = idRegex.replace(sessionId, "_") + ".json"
142 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(
665 kotlinx.coroutines.runBlocking {
665 kotlinx.coroutines.runBlocking {
666 withTimeoutOrNull(2_000) {
666 withTimeoutOrNull(2_000) {
667 connectionMutex.withLock {
667 connectionMutex.withLock {
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`.
668− runCatching { _activeConnection.value?.close() }
673+ runCatchingCancellable { _activeConnection.value?.close() }
669 _activeConnection.value = null
674 _activeConnection.value = null
670 closed = true
675 closed = true
671 }
676 }
@@ -681,7 +686,10 @@ open class AppContainer private constructor(
681 // stuck connection's scope directly. close() suspends (scopeJob.join), so
686 // stuck connection's scope directly. close() suspends (scopeJob.join), so
682 // wrap in a bounded runBlocking that can't ANR — at this point the app is
687 // wrap in a bounded runBlocking that can't ANR — at this point the app is
683 // shutting down, so reaping is best-effort.
688 // 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 {
685 kotlinx.coroutines.runBlocking {
693 kotlinx.coroutines.runBlocking {
686 kotlinx.coroutines.withTimeoutOrNull(1_000) { stuck.close() }
694 kotlinx.coroutines.withTimeoutOrNull(1_000) { stuck.close() }
687 }
695 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+25−7
@@ -1066,7 +1066,10 @@ fun ChatScreen(
1066 // New-content signal for the jump-to-latest FAB: counts how many new items arrived
1066 // New-content signal for the jump-to-latest FAB: counts how many new items arrived
1067 // while the user was scrolled away from the bottom, cleared once they return. A count
1067 // while the user was scrolled away from the bottom, cleared once they return. A count
1068 // (vs. a bare dot) tells the user how much they missed at a glance.
1068 // (vs. a bare dot) tells the user how much they missed at a glance.
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.
1069− var newContentCount by remember { mutableIntStateOf(0) }
1072+ var newContentCount by rememberSaveable { mutableIntStateOf(0) }
1070
1073
1071 val isPinnedToBottom by remember {
1074 val isPinnedToBottom by remember {
1072 derivedStateOf {
1075 derivedStateOf {
@@ -1116,13 +1119,24 @@ fun ChatScreen(
1116 // Scroll the focused search match into view whenever the user steps through results with
1119 // Scroll the focused search match into view whenever the user steps through results with
1117 // the up/down arrows. Resolves the filtered message to its slot in listItems (which
1120 // the up/down arrows. Resolves the filtered message to its slot in listItems (which
1118 // includes date separators) and animates there.
1121 // includes date separators) and animates there.
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.
1119− LaunchedEffect(searchPos, searchQuery, listItems) {
1130+ LaunchedEffect(searchPos, searchQuery) {
1120 if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
1131 if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
1121 val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
1132 val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
1133+ val targetId = target.info.id
1134+ val index = snapshotFlow {
1122− val index = listItems.indexOfFirst {
1135+ currentListItems.indexOfFirst {
1123− it is MessageListItem.Message && it.message.info.id == target.info.id
1136+ it is MessageListItem.Message && it.message.info.id == targetId
1124− }
1137+ }
1138+ }.first { it >= 0 }
1125− if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
1139+ runCatchingCancellable { listState.animateScrollToItem(index) }
1126 }
1140 }
1127
1141
1128 // Global-search deep link: scroll to and briefly highlight the matched message. The
1142 // Global-search deep link: scroll to and briefly highlight the matched message. The
@@ -1136,7 +1150,11 @@ fun ChatScreen(
1136 // actively-streaming chat unreadable. snapshotFlow waits for the message to appear in the
1150 // actively-streaming chat unreadable. snapshotFlow waits for the message to appear in the
1137 // list (it may not be present on the first composition while messages load) without
1151 // list (it may not be present on the first composition while messages load) without
1138 // re-keying the effect on every list change.
1152 // re-keying the effect on every list change.
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.
1139− var focusedMessageId by remember(focusMessageId) { mutableStateOf(focusMessageId) }
1157+ var focusedMessageId by rememberSaveable(focusMessageId) { mutableStateOf(focusMessageId) }
1140 val currentListItemsForFocus by rememberUpdatedState(listItems)
1158 val currentListItemsForFocus by rememberUpdatedState(listItems)
1141 LaunchedEffect(focusedMessageId) {
1159 LaunchedEffect(focusedMessageId) {
1142 val focus = focusedMessageId ?: return@LaunchedEffect
1160 val focus = focusedMessageId ?: return@LaunchedEffect
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+11
@@ -1188,6 +1188,17 @@ class ChatViewModel(
1188 // the launch without wiping the persisted follow-up — the follow-up is then
1188 // the launch without wiping the persisted follow-up — the follow-up is then
1189 // recovered on the next session open instead of being silently lost.
1189 // recovered on the next session open instead of being silently lost.
1190 if (queuedFollowUpToClear != null) setQueuedFollowUp(null)
1190 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+ }
1191 runCatchingCancellable {
1202 runCatchingCancellable {
1192 conn.repository.sendPrompt(
1203 conn.repository.sendPrompt(
1193 sessionId,
1204 sessionId,
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+12−9
@@ -236,25 +236,28 @@ fun FileBrowserScreen(
236 val loadingLabel = stringResource(R.string.loading)
236 val loadingLabel = stringResource(R.string.loading)
237 // Crossfade between content states so transitions read as a smooth fade
237 // Crossfade between content states so transitions read as a smooth fade
238 // instead of an instant snap. Matches the session list's Crossfade pattern;
238 // 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.
240 val stateKey = fileBrowserStateKey(state)
244 val stateKey = fileBrowserStateKey(state)
241− @Suppress("UnusedCrossfadeTargetStateParameter")
242 Crossfade(
245 Crossfade(
243 targetState = stateKey,
246 targetState = stateKey,
244 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
247 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
245 label = "file_browser_state",
248 label = "file_browser_state",
246− ) {
249+ ) { key ->
247− when {
250+ when (key) {
248 // Skeleton loader for the initial directory load. An in-flight search
251 // Skeleton loader for the initial directory load. An in-flight search
249 // keeps the previous results visible (a slim top bar below shows the
252 // keeps the previous results visible (a slim top bar below shows the
250 // progress) instead of blanking the list on every keystroke.
253 // progress) instead of blanking the list on every keystroke.
251− state.loading -> Column(
254+ "loading" -> Column(
252 modifier = Modifier.fillMaxSize().padding(16.dp),
255 modifier = Modifier.fillMaxSize().padding(16.dp),
253 verticalArrangement = Arrangement.spacedBy(12.dp),
256 verticalArrangement = Arrangement.spacedBy(12.dp),
254 ) {
257 ) {
255 repeat(6) { SkeletonRow() }
258 repeat(6) { SkeletonRow() }
256 }
259 }
257− state.error != null -> {
260+ "error" -> {
258 // Detect a permission-denied error (the server returns a 403/EACCES
261 // Detect a permission-denied error (the server returns a 403/EACCES
259 // when the workspace directory isn't readable) and show a specific
262 // when the workspace directory isn't readable) and show a specific
260 // message + icon instead of the generic error, so the user
263 // message + icon instead of the generic error, so the user
@@ -297,17 +300,17 @@ fun FileBrowserScreen(
297 }
300 }
298 }
301 }
299 }
302 }
300− state.mode == SearchMode.TEXT ->
303+ "text" ->
301 TextResults(
304 TextResults(
302 state.textResults,
305 state.textResults,
303 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
306 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
304 ) { path, line -> onOpenFile(path, line) }
307 ) { path, line -> onOpenFile(path, line) }
305− state.mode == SearchMode.SYMBOL ->
308+ "symbol" ->
306 SymbolResults(
309 SymbolResults(
307 state.symbolResults,
310 state.symbolResults,
308 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
311 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
309 ) { path, line -> onOpenFile(path, line) }
312 ) { path, line -> onOpenFile(path, line) }
310− state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
313+ "search" -> SearchResults(state.results) { onOpenFile(it, null) }
311 else -> DirectoryListing(
314 else -> DirectoryListing(
312 container = container,
315 container = container,
313 state = state,
316 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) {
142 ConnectionBannerFor(container)
142 ConnectionBannerFor(container)
143 // Crossfade between content states so transitions read as a smooth fade instead
143 // Crossfade between content states so transitions read as a smooth fade instead
144 // of an instant snap. Matches the session list's Crossfade pattern; reduced
144 // 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.
146 val stateKey = state::class.simpleName
150 val stateKey = state::class.simpleName
147− @Suppress("UnusedCrossfadeTargetStateParameter")
148 Crossfade(
151 Crossfade(
149 targetState = stateKey,
152 targetState = stateKey,
150 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
153 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
151 label = "mcp_state",
154 label = "mcp_state",
152− ) {
155+ ) { key ->
153− when (val s = state) {
156+ when (key) {
154− is McpViewModel.State.Loading -> Column(
157+ "Loading" -> Column(
155 modifier = Modifier.fillMaxSize().padding(16.dp),
158 modifier = Modifier.fillMaxSize().padding(16.dp),
156 verticalArrangement = Arrangement.spacedBy(12.dp),
159 verticalArrangement = Arrangement.spacedBy(12.dp),
157 ) {
160 ) {
158 repeat(6) { SkeletonRow() }
161 repeat(6) { SkeletonRow() }
159 }
162 }
160− is McpViewModel.State.Disconnected -> EmptyState(
163+ "Disconnected" -> EmptyState(
161 icon = Icons.Filled.CloudOff,
164 icon = Icons.Filled.CloudOff,
162 title = stringResource(R.string.not_connected),
165 title = stringResource(R.string.not_connected),
163 modifier = Modifier.align(Alignment.Center),
166 modifier = Modifier.align(Alignment.Center),
164 )
167 )
165− is McpViewModel.State.Error -> EmptyState(
168+ "Error" -> EmptyState(
166 icon = Icons.Filled.ErrorOutline,
169 icon = Icons.Filled.ErrorOutline,
167 title = stringResource(R.string.mcp_failed),
170 title = stringResource(R.string.mcp_failed),
168− description = s.message,
171+ description = (state as? McpViewModel.State.Error)?.message,
169 modifier = Modifier.align(Alignment.Center),
172 modifier = Modifier.align(Alignment.Center),
170 actionLabel = stringResource(R.string.retry),
173 actionLabel = stringResource(R.string.retry),
171 onAction = { vm.load() },
174 onAction = { vm.load() },
172 )
175 )
176+ "Ready" ->
173− is McpViewModel.State.Ready ->
177+ (state as? McpViewModel.State.Ready)?.let { s ->
174− if (s.servers.isEmpty()) {
178+ if (s.servers.isEmpty()) {
175− EmptyState(
179+ EmptyState(
176− icon = Icons.Filled.Hub,
180+ icon = Icons.Filled.Hub,
177− title = stringResource(R.string.mcp_empty),
181+ title = stringResource(R.string.mcp_empty),
178− modifier = Modifier.align(Alignment.Center),
182+ modifier = Modifier.align(Alignment.Center),
179− actionIcon = Icons.Filled.Add,
183+ actionIcon = Icons.Filled.Add,
180− actionLabel = stringResource(R.string.mcp_add_server),
184+ actionLabel = stringResource(R.string.mcp_add_server),
181− onAction = { showAddDialog = true },
185+ onAction = { showAddDialog = true },
182− )
186+ )
183− } else {
187+ } else {
184− LazyColumn(
188+ LazyColumn(
185− modifier = Modifier.fillMaxWidth(),
189+ modifier = Modifier.fillMaxWidth(),
186− contentPadding = PaddingValues(
190+ contentPadding = PaddingValues(
187− start = 16.dp, end = 16.dp, top = 16.dp, bottom = soy.iko.opencode.data.network.NetworkConfig.listFabInsetDp.dp,
191+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = soy.iko.opencode.data.network.NetworkConfig.listFabInsetDp.dp,
188− ),
192+ ),
189− verticalArrangement = Arrangement.spacedBy(12.dp),
193+ verticalArrangement = Arrangement.spacedBy(12.dp),
190− ) {
194+ ) {
191− items(s.servers, key = { it.name }) {
195+ items(s.servers, key = { it.name }) {
192− McpServerCard(it, modifier = Modifier.then(reducedMotionAnimateItem()))
196+ McpServerCard(it, modifier = Modifier.then(reducedMotionAnimateItem()))
197+ }
193 }
198 }
194 }
199 }
195 }
200 }
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+13−10
@@ -163,16 +163,19 @@ fun GlobalSearchScreen(
163 val showResultsWhileSearching = state.searching && state.hasSearched && state.results.isNotEmpty()
163 val showResultsWhileSearching = state.searching && state.hasSearched && state.results.isNotEmpty()
164 // Crossfade between content states so transitions read as a smooth fade instead
164 // Crossfade between content states so transitions read as a smooth fade instead
165 // of an instant snap. Matches the session list's Crossfade pattern; reduced
165 // 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.
167 val stateKey = globalSearchStateKey(state)
171 val stateKey = globalSearchStateKey(state)
168− @Suppress("UnusedCrossfadeTargetStateParameter")
169 Crossfade(
172 Crossfade(
170 targetState = stateKey,
173 targetState = stateKey,
171 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
174 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
172 label = "global_search_state",
175 label = "global_search_state",
173− ) {
176+ ) { key ->
174− when {
177+ when (key) {
175− state.searching && !showResultsWhileSearching -> Column(
178+ "searching" -> Column(
176 modifier = Modifier.align(Alignment.Center),
179 modifier = Modifier.align(Alignment.Center),
177 horizontalAlignment = Alignment.CenterHorizontally,
180 horizontalAlignment = Alignment.CenterHorizontally,
178 ) {
181 ) {
@@ -189,14 +192,14 @@ fun GlobalSearchScreen(
189 )
192 )
190 }
193 }
191 }
194 }
192− state.error != null -> EmptyState(
195+ "error" -> EmptyState(
193 icon = Icons.Filled.ErrorOutline,
196 icon = Icons.Filled.ErrorOutline,
194 title = state.error ?: "",
197 title = state.error ?: "",
195 modifier = Modifier.align(Alignment.Center),
198 modifier = Modifier.align(Alignment.Center),
196 actionLabel = stringResource(R.string.retry),
199 actionLabel = stringResource(R.string.retry),
197 onAction = vm::retry,
200 onAction = vm::retry,
198 )
201 )
199− state.hasSearched && state.results.isEmpty() -> EmptyState(
202+ "no_matches" -> EmptyState(
200 icon = Icons.Filled.SearchOff,
203 icon = Icons.Filled.SearchOff,
201 title = stringResource(R.string.search_no_matches),
204 title = stringResource(R.string.search_no_matches),
202 modifier = Modifier.align(Alignment.Center),
205 modifier = Modifier.align(Alignment.Center),
@@ -204,14 +207,14 @@ fun GlobalSearchScreen(
204 // Typed something but below the min length (no search run yet): nudge to keep
207 // Typed something but below the min length (no search run yet): nudge to keep
205 // typing rather than reverting to the untouched start state, which reads as if
208 // typing rather than reverting to the untouched start state, which reads as if
206 // the field were empty.
209 // the field were empty.
207− state.query.isNotBlank() && !state.hasSearched -> EmptyState(
210+ "keep_typing" -> EmptyState(
208 icon = Icons.Filled.Search,
211 icon = Icons.Filled.Search,
209 title = stringResource(R.string.search_keep_typing),
212 title = stringResource(R.string.search_keep_typing),
210 modifier = Modifier.align(Alignment.Center),
213 modifier = Modifier.align(Alignment.Center),
211 )
214 )
212 // Empty query + persisted history: surface suggestions so a returning user can
215 // Empty query + persisted history: surface suggestions so a returning user can
213 // re-run a prior search in one tap instead of retyping it.
216 // re-run a prior search in one tap instead of retyping it.
214− state.query.isBlank() && state.history.isNotEmpty() -> HistorySuggestions(
217+ "history" -> HistorySuggestions(
215 history = state.history,
218 history = state.history,
216 onPick = { query ->
219 onPick = { query ->
217 vm.setQuery(query)
220 vm.setQuery(query)
@@ -220,7 +223,7 @@ fun GlobalSearchScreen(
220 onClear = vm::clearHistory,
223 onClear = vm::clearHistory,
221 modifier = Modifier.fillMaxSize(),
224 modifier = Modifier.fillMaxSize(),
222 )
225 )
223− state.results.isEmpty() -> EmptyState(
226+ "start" -> EmptyState(
224 icon = Icons.Filled.Search,
227 icon = Icons.Filled.Search,
225 title = stringResource(R.string.search_all_start),
228 title = stringResource(R.string.search_all_start),
226 description = stringResource(R.string.search_all_start_hint),
229 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) {
282 )
282 )
283 // Crossfade between empty and list states so the transition reads as a smooth fade
283 // Crossfade between empty and list states so the transition reads as a smooth fade
284 // instead of an instant snap. Matches the session list's Crossfade pattern; reduced
284 // 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.
286 val stateKey = if (reports.isEmpty()) "empty" else "list"
290 val stateKey = if (reports.isEmpty()) "empty" else "list"
287− @Suppress("UnusedCrossfadeTargetStateParameter")
288 Crossfade(
291 Crossfade(
289 targetState = stateKey,
292 targetState = stateKey,
290 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
293 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
291 label = "diagnostics_state",
294 label = "diagnostics_state",
292− ) {
295+ ) { key ->
293− if (reports.isEmpty()) {
296+ if (key == "empty") {
294 EmptyState(
297 EmptyState(
295 icon = Icons.Filled.BugReport,
298 icon = Icons.Filled.BugReport,
296 title = stringResource(R.string.no_crash_reports),
299 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
108 ConnectionBannerFor(container)
108 ConnectionBannerFor(container)
109 // Crossfade between content states so transitions read as a smooth fade instead
109 // Crossfade between content states so transitions read as a smooth fade instead
110 // of an instant snap. Matches the session list's Crossfade pattern; reduced
110 // 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.
112 val stateKey = state::class.simpleName
116 val stateKey = state::class.simpleName
113− @Suppress("UnusedCrossfadeTargetStateParameter")
114 Crossfade(
117 Crossfade(
115 targetState = stateKey,
118 targetState = stateKey,
116 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
119 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
117 label = "usage_state",
120 label = "usage_state",
118− ) {
121+ ) { key ->
119− when (val s = state) {
122+ when (key) {
120− is UsageViewModel.State.Loading -> Column(
123+ "Loading" -> Column(
121 modifier = Modifier.fillMaxSize().padding(16.dp),
124 modifier = Modifier.fillMaxSize().padding(16.dp),
122 verticalArrangement = Arrangement.spacedBy(12.dp),
125 verticalArrangement = Arrangement.spacedBy(12.dp),
123 ) {
126 ) {
124 repeat(6) { SkeletonRow() }
127 repeat(6) { SkeletonRow() }
125 }
128 }
126− is UsageViewModel.State.Disconnected -> EmptyState(
129+ "Disconnected" -> EmptyState(
127 icon = Icons.Filled.CloudOff,
130 icon = Icons.Filled.CloudOff,
128 title = stringResource(R.string.not_connected),
131 title = stringResource(R.string.not_connected),
129 modifier = Modifier.align(Alignment.Center),
132 modifier = Modifier.align(Alignment.Center),
130 )
133 )
131− is UsageViewModel.State.Error -> EmptyState(
134+ "Error" -> EmptyState(
132 icon = Icons.Filled.ErrorOutline,
135 icon = Icons.Filled.ErrorOutline,
133 title = stringResource(R.string.usage_failed),
136 title = stringResource(R.string.usage_failed),
134− description = s.message,
137+ description = (state as? UsageViewModel.State.Error)?.message,
135 modifier = Modifier.align(Alignment.Center),
138 modifier = Modifier.align(Alignment.Center),
136 actionLabel = stringResource(R.string.retry),
139 actionLabel = stringResource(R.string.retry),
137 onAction = { vm.load() },
140 onAction = { vm.load() },
138 )
141 )
142+ "Ready" ->
139− is UsageViewModel.State.Ready ->
143+ (state as? UsageViewModel.State.Ready)?.let { s ->
140− if (s.report.isEmpty) {
144+ if (s.report.isEmpty) {
141− EmptyState(
145+ EmptyState(
142− icon = Icons.Filled.QueryStats,
146+ icon = Icons.Filled.QueryStats,
143− title = stringResource(R.string.usage_empty),
147+ title = stringResource(R.string.usage_empty),
144− description = stringResource(R.string.usage_empty_desc),
148+ description = stringResource(R.string.usage_empty_desc),
145− modifier = Modifier.align(Alignment.Center),
149+ modifier = Modifier.align(Alignment.Center),
146− )
150+ )
147− } else {
151+ } else {
148− val timeRange by vm.timeRange.collectAsStateWithLifecycle()
152+ val timeRange by vm.timeRange.collectAsStateWithLifecycle()
149− UsageContent(
153+ UsageContent(
150− report = s.report,
154+ report = s.report,
151− onOpenSession = onOpenSession,
155+ onOpenSession = onOpenSession,
152− timeRange = timeRange,
156+ timeRange = timeRange,
153− onTimeRangeChange = vm::setTimeRange,
157+ onTimeRangeChange = vm::setTimeRange,
154− )
158+ )
159+ }
155 }
160 }
156 }
161 }
157 }
162 }
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+20−6
@@ -102,7 +102,12 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
102 // spinner on the first load.
102 // spinner on the first load.
103 if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
103 if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
104 try {
104 try {
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.
105− runCatchingCancellable {
110+ val fetched = runCatchingCancellable {
106 val sessions = conn.repository.listSessions()
111 val sessions = conn.repository.listSessions()
107 // Cap concurrent message fetches so a large history doesn't open dozens of
112 // Cap concurrent message fetches so a large history doesn't open dozens of
108 // sockets at once; a per-session fetch failure yields an empty list rather
113 // sockets at once; a per-session fetch failure yields an empty list rather
@@ -111,7 +116,7 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
111 // coroutineScope so the per-session fetches are children of THIS load: when
116 // coroutineScope so the per-session fetches are children of THIS load: when
112 // collectLatest cancels a stale load (connection changed / manual reload), the
117 // collectLatest cancels a stale load (connection changed / manual reload), the
113 // in-flight fetches are cancelled with it instead of leaking.
118 // in-flight fetches are cancelled with it instead of leaking.
114− val perSession = coroutineScope {
119+ coroutineScope {
115 sessions.map { session ->
120 sessions.map { session ->
116 async {
121 async {
117 limiter.withPermit {
122 limiter.withPermit {
@@ -127,12 +132,21 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
127 }
132 }
128 }.awaitAll()
133 }.awaitAll()
129 }
134 }
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()
130− cachedInputs = perSession
145+ cachedInputs = inputs
131 val cutoff = _timeRange.value.cutoffMs()
146 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)
133 }
149 }
134− .onSuccess { _state.value = State.Ready(it) }
135− .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
136 } finally {
150 } finally {
137 // Reset in finally so a collectLatest cancellation (which rethrows
151 // Reset in finally so a collectLatest cancellation (which rethrows
138 // CancellationException out of runCatchingCancellable, skipping the lines above)
152 // CancellationException out of runCatchingCancellable, skipping the lines above)