git.iko.soy/opencode-android
on master help sign in
Fix eight bugs across data, viewmodel, and UI layers
commit8a50d4d5b943c51472eab00f9e2095b8a8cab451 authoriko <mail@iko.soy> dateTue 7 Jul 2026, 20:22 (3 months ago) parent00849aa
12 files changed+158−56
app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt+9−5 app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+9−7 app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+9−5 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+35−2 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+8 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+20−16 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+17−6 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+4−1 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+18−4 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+8−2 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+4−3 app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+17−5
app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt+9−5
@@ -85,13 +85,17 @@ open class AttachmentDraftStore private constructor(
8585 // and silently drops the staged attachments on reload.
8686 val encoded = json.encodeToString(attachments)
8787 val tmp = File(file.parentFile, file.name + ".tmp")
88− tmp.writeText(encoded)
89− if (!tmp.renameTo(file)) {
90− try {
88+ // One try/finally covers BOTH write paths: a throw from the initial
89+ // tmp.writeText (disk full, permission) is cleaned up too — otherwise the
90+ // inner finally below only ran after the rename failed, orphaning *.tmp
91+ // files across failed first writes.
92+ try {
93+ tmp.writeText(encoded)
94+ if (!tmp.renameTo(file)) {
9195 file.writeText(encoded)
92− } finally {
93− tmp.delete()
9496 }
97+ } finally {
98+ tmp.delete()
9599 }
96100 }
97101 }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+9−7
@@ -99,15 +99,17 @@ open class MessageCacheStore private constructor(
9999 // wiping the cached conversation on the next open.
100100 val encoded = OpencodeJson.encodeToString(serializer, messages)
101101 val tmp = File(file.parentFile, file.name + ".tmp")
102− tmp.writeText(encoded)
103− if (!tmp.renameTo(file)) {
104− // try/finally so a throw from file.writeText (disk full, permission) still
105− // cleans up tmp — otherwise *.tmp files accumulate across failed writes.
106− try {
102+ // One try/finally covers BOTH write paths: a throw from the initial
103+ // tmp.writeText (disk full, permission) is cleaned up too — otherwise the
104+ // inner finally below only ran after the rename failed, orphaning *.tmp
105+ // files across failed first writes.
106+ try {
107+ tmp.writeText(encoded)
108+ if (!tmp.renameTo(file)) {
107109 file.writeText(encoded)
108− } finally {
109− tmp.delete()
110110 }
111+ } finally {
112+ tmp.delete()
111113 }
112114 }
113115 }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+9−5
@@ -156,13 +156,17 @@ open class OutboxStore private constructor(
156156 // decode and load() would then drop ALL queued offline messages.
157157 val encoded = json.encodeToString(list)
158158 val tmp = File(f.parentFile, f.name + ".tmp")
159− tmp.writeText(encoded)
160− if (!tmp.renameTo(f)) {
161− try {
159+ // One try/finally covers BOTH write paths: a throw from the initial
160+ // tmp.writeText (disk full, permission) is cleaned up too — otherwise the
161+ // inner finally below only ran after the rename failed, orphaning *.tmp
162+ // files across failed first writes.
163+ try {
164+ tmp.writeText(encoded)
165+ if (!tmp.renameTo(f)) {
162166 f.writeText(encoded)
163− } finally {
164− tmp.delete()
165167 }
168+ } finally {
169+ tmp.delete()
166170 }
167171 }
168172 }.onFailure { Log.w("OutboxStore", "Failed to persist outbox", it) }
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+35−2
@@ -610,7 +610,23 @@ internal class MessageStore {
610610 // behavior for a genuinely new message.
611611 else -> {
612612 val mapKey = resolveMapKey(rawMessageId) ?: rawMessageId
613− if (partSession != null) {
613+ // An evicted UnknownMessage with an empty id was recorded in
614+ // evictedMessageIds under its synthetic "unknown-c<created>" /
615+ // "unknown-h<hash>" key (see evictOldMessages). resolveMapKey("")
616+ // returns null (the holder is gone), so mapKey falls back to "" —
617+ // which isn't in the set. Drop the late part instead of re-inserting
618+ // a partial bubble. The set could contain synthetic keys for any of
619+ // possibly-several evicted unknown messages; without the message's
620+ // creation time on the Part we can't disambiguate, so treat ANY
621+ // evicted synthetic key as a match. This is over-broad (a genuinely-
622+ // new unknown message's first part would be dropped if an unrelated
623+ // one was just evicted), but UnknownMessages from unrecognized server
624+ // roles are rare, and a following MessageUpdated (which always
625+ // carries full info) re-creates the holder correctly either way.
626+ if (rawMessageId.isEmpty() && mapKey.isEmpty() &&
627+ evictedMessageIds.any { it.startsWith("unknown-") }) {
628+ false
629+ } else if (partSession != null) {
614630 if (partSession == sessionId) upsertPart(mapKey, part) else false
615631 } else {
616632 // No session id anywhere on the event: a null session is NOT a wildcard
@@ -656,7 +672,11 @@ internal class MessageStore {
656672 // A MessageUpdated re-introduces the message into the store (it was either evicted
657673 // by evictOldMessages or genuinely new). Drop any eviction record so a following
658674 // MessagePartUpdated updates this holder instead of being dropped as "late".
675+ // For an empty-id UnknownMessage the record was stored under the synthetic key (see
676+ // evictOldMessages), so remove that too — otherwise the over-broad empty-id guard in
677+ // reduce() would keep dropping parts for this message even after it's back in memory.
659678 info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
679+ if (info.id.isEmpty()) evictedMessageIds.remove(key)
660680 if (existing == null) {
661681 messages[key] = Holder(info, LinkedHashMap())
662682 } else {
@@ -705,6 +725,9 @@ internal class MessageStore {
705725 // The message is gone from the store; drop any eviction record for it so the set
706726 // doesn't retain ids of long-deleted messages. (A re-seed would also clear this,
707727 // but a quiet session that never reconnects could otherwise accumulate entries.)
728+ // For an empty-id UnknownMessage the record was stored under the synthetic key (see
729+ // evictOldMessages), so remove that too — keep the set bounded and prevent the
730+ // over-broad empty-id guard in reduce() from dropping parts for a reused id.
708731 removed.info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
709732 // Derive the same key handleMessageUpdated would have stored this holder under. For an
710733 // UnknownMessage with an empty id, that's the synthetic "unknown-c<created>" /
@@ -718,6 +741,7 @@ internal class MessageStore {
718741 } else {
719742 id
720743 }
744+ if (removed.info.id.isEmpty()) evictedMessageIds.remove(pivotKey)
721745 messageInfoUpdatedSincePivot.remove(pivotKey)
722746 removed.parts.keys.forEach { streamedSincePivot.remove(it) }
723747 return true
@@ -786,10 +810,19 @@ internal class MessageStore {
786810 // Record the evicted message's id so a late MessagePartUpdated for it is dropped
787811 // instead of re-inserting a single-part bubble (see upsertPart). Also clear any
788812 // prior eviction record for this id from an older cycle (the message re-entered
789− // via seed/MessageUpdated and has now been evicted again).
813+ // via seed/MessageUpdated and has now been evicted again). Record the MAP KEY
814+ // (oldestKey), not just info.id: an UnknownMessage with an empty id is stored
815+ // under a synthetic "unknown-c<created>" / "unknown-h<hash>" key, so recording
816+ // only info.id (which is "") would never match — a late MessagePartUpdated for
817+ // it would resolveMapKey("") → null → fall back to upsertPart("") and re-insert
818+ // a partial bubble. Recording the synthetic key lets the empty-id guard below
819+ // (in reduce) catch it.
790820 removed?.info?.id?.takeIf { it.isNotEmpty() }?.let { id ->
791821 evictedMessageIds.add(id)
792822 }
823+ if (removed != null && removed.info.id.isEmpty()) {
824+ evictedMessageIds.add(oldestKey)
825+ }
793826 // Prune the evicted message's pivot bookkeeping so these sets don't grow
794827 // unbounded over a long-running session that never reconnects (they're only
795828 // otherwise cleared on reseed/seed).
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+8
@@ -1254,6 +1254,10 @@ open class AppContainer private constructor(
12541254 synchronized(activeRuns) {
12551255 activeRuns.clear()
12561256 publishRunState()
1257+ // Clear the progress text too: with no SSE stream there's no SessionIdle to drop
1258+ // it (line ~789 does it on idle), so without this a mid-run server switch would
1259+ // leave "Step 2 of 5" exposed in the public StateFlow with no run actually active.
1260+ _runProgressText.value = null
12571261 }
12581262 if (previousProfileId != profile.id) {
12591263 _unread.value = emptyMap()
@@ -1298,6 +1302,10 @@ open class AppContainer private constructor(
12981302 synchronized(activeRuns) {
12991303 activeRuns.clear()
13001304 publishRunState()
1305+ // Clear the progress text too: with no SSE stream there's no SessionIdle to drop
1306+ // it (line ~789 does it on idle), so without this a disconnect mid-run would leave
1307+ // "Step 2 of 5" exposed in the public StateFlow with no run actually active.
1308+ _runProgressText.value = null
13011309 }
13021310 _unread.value = emptyMap()
13031311 unreadMessageIds.clear()
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+20−16
@@ -1528,28 +1528,32 @@ fun ChatScreen(
15281528 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
15291529 ?.let { resolveModelLabel(it, models) }
15301530 }
1531− // Key agentLabel/quoteText on the message id (not message.parts) so
1532− // the streaming message — whose `parts` list is replaced on every
1533− // token — doesn't re-run filterIsInstance per token for these two
1534− // derived labels. For a non-streaming message, `message.parts` is
1535− // reference-stable anyway, so this is equivalent. For the streaming
1536− // message the label updates as parts change via the message.info/
1537− // parts reference change below — keyed on messageId + a parts-hash
1538− // is overkill; agentLabel/quoteText only need to reflect the latest
1539− // parts, and `remember(messageId, message.parts)` would re-key per
1540− // token. Instead key on `messageId` alone and accept that the label
1541− // lags by one recomposition for the streaming bubble (it stabilizes
1542− // once the stream finishes). This is invisible in practice: agent
1543− // label is set on the first part, quote text grows with text parts
1544− // but is only consumed on a swipe (user-initiated, post-stream).
1545− val agentLabel = remember(messageId) {
1531+ // Key agentLabel/quoteText on the message id + the id of the
1532+ // last TextPart/AgentPart, so the streaming message's label
1533+ // updates when a NEW text/agent part is appended (its id
1534+ // changes) without re-running filterIsInstance per token (a
1535+ // streaming TextPart's id is stable across token updates, so
1536+ // the key only changes on append, not on every token). The
1537+ // prior `remember(messageId)` pinned these at first
1538+ // composition: for a message that started with reasoning/tool
1539+ // parts before any TextPart, quoteText stayed "" for the life
1540+ // of the list item, so swipe-to-reply yielded "cannot quote
1541+ // this" even after text arrived.
1542+ val lastTextPartId = remember(message.parts) {
1543+ message.parts.lastOrNull { it is TextPart }?.id
1544+ }
1545+ val lastAgentPartId = remember(message.parts) {
1546+ message.parts.filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1547+ .lastOrNull { it.name.isNotBlank() }?.id
1548+ }
1549+ val agentLabel = remember(messageId, lastAgentPartId) {
15461550 message.parts
15471551 .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
15481552 .firstOrNull { it.name.isNotBlank() }?.name
15491553 }
15501554 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
15511555 // image/code-only messages, which then opt out of the swipe gesture.
1552− val quoteText = remember(messageId) {
1556+ val quoteText = remember(messageId, lastTextPartId) {
15531557 message.parts
15541558 .filterIsInstance<TextPart>()
15551559 .joinToString("\n\n") { it.text }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+17−6
@@ -1192,10 +1192,14 @@ class ChatViewModel(
11921192 // server switch between send() returning and this coroutine starting would send the
11931193 // prompt to the OLD (closed) server. Route to the outbox instead so the message is
11941194 // 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
1195+ // connection. Pass the ORIGINAL connection's profile id so the outbox attributes the
1196+ // message to the server the user was typing against — without this, enqueueOffline
1197+ // would re-read container.activeConnection.value (now the NEW server) and queue the
1198+ // message against a session id that doesn't exist there, 404ing on flush.
1199+ // Reset _running (set by beginRun above) since no SSE streaming will
11961200 // follow an outbox enqueue — without this the run indicator would be stuck on.
11971201 if (container.activeConnection.value !== conn) {
1198− enqueueOffline(trimmed, attachments)
1202+ enqueueOffline(trimmed, attachments, profileId = conn.profile.id)
11991203 _running.value = false
12001204 return@launch
12011205 }
@@ -1259,8 +1263,14 @@ class ChatViewModel(
12591263 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
12601264 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
12611265 * are restored so nothing is lost.
1266+ *
1267+ * [profileId] pins the message to a specific server: when [send] re-routes here because a
1268+ * server switch was detected mid-send, it passes the ORIGINAL connection's profile id so
1269+ * the queued message flushes against the session on that server, not the new one (which
1270+ * may not have a session with this id and would 404). When null (the user was genuinely
1271+ * offline with no connection), the profile is resolved at enqueue time as before.
12621272 */
1263− private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
1273+ private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>, profileId: String? = null): Boolean {
12641274 _failedDraft.value = null
12651275 if (attachments.isNotEmpty()) _attachments.value = emptyList()
12661276 val clearDraft = _draft.value.trim() == trimmed
@@ -1269,9 +1279,10 @@ class ChatViewModel(
12691279 _draft.value = ""
12701280 }
12711281 viewModelScope.launch {
1272− val profileId = connection?.profile?.id
1282+ val resolvedProfileId = profileId
1283+ ?: connection?.profile?.id
12731284 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1274− if (profileId == null) {
1285+ if (resolvedProfileId == null) {
12751286 // No server to attribute the message to — restore the composer and report it
12761287 // rather than silently dropping the prompt.
12771288 suppressDraftPersist.set(false)
@@ -1285,7 +1296,7 @@ class ChatViewModel(
12851296 container.outboxStore.enqueue(
12861297 OutboxMessage(
12871298 id = java.util.UUID.randomUUID().toString(),
1288− profileId = profileId,
1299+ profileId = resolvedProfileId,
12891300 sessionId = sessionId,
12901301 text = trimmed,
12911302 attachments = attachments.map { it.toPersisted() },
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+4−1
@@ -61,6 +61,7 @@ import androidx.compose.material3.Text
6161import androidx.compose.runtime.Composable
6262import androidx.compose.runtime.LaunchedEffect
6363import androidx.compose.runtime.getValue
64+import androidx.compose.runtime.mutableIntStateOf
6465import androidx.compose.runtime.mutableStateOf
6566import androidx.compose.runtime.remember
6667import androidx.compose.runtime.rememberCoroutineScope
@@ -718,7 +719,9 @@ private fun DirectoryListing(
718719 // so the order is correct — only the composed slice is capped). Mirrors the
719720 // session list's windowed render pattern. Hoisted above the LazyColumn because
720721 // state reads must be in a @Composable scope (LazyListScope is not one).
721− var renderCap by remember { mutableStateOf(NetworkConfig.fileListInitialPage) }
722+ // rememberSaveable (not plain remember) so the window survives a rotation alongside
723+ // the LazyListState — see SessionListScreen for the clamp-and-regrow jank otherwise.
724+ var renderCap by rememberSaveable { mutableIntStateOf(NetworkConfig.fileListInitialPage) }
722725 val visibleSorted = remember(sorted, renderCap) {
723726 if (sorted.size <= renderCap) sorted else sorted.take(renderCap)
724727 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+18−4
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow
1717import kotlinx.coroutines.flow.asSharedFlow
1818import kotlinx.coroutines.flow.asStateFlow
1919import kotlinx.coroutines.flow.combine
20+import kotlinx.coroutines.flow.first
2021import kotlinx.coroutines.flow.map
2122import kotlinx.coroutines.flow.stateIn
2223import kotlinx.coroutines.flow.update
@@ -199,12 +200,25 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
199200 * the server list. Gives the user a familiar gesture to retry a failed auto-connect
200201 * instead of having to tap a server card. Targets the profile with the highest
201202 * lastUsed timestamp (the most natural "retry the one I was just on"), falling back
202− * to the first profile if none has been used yet. */
203+ * to the first profile if none has been used yet.
204+ *
205+ * Reads from `profileStore.profiles.first()` (not the local [profiles] StateFlow) because
206+ * [profiles] is `stateIn(... initialValue = emptyList())` — on a cold start, before
207+ * DataStore has emitted, the cached value is empty even if a most-recently-used profile
208+ * exists on disk. Reading the cached value would silently no-op the retry gesture exactly
209+ * when the user most needs it. The DataStore read suspends briefly, so this runs in a
210+ * coroutine and re-checks the connecting/reconnecting guards before connecting. */
203211 fun refresh(onConnected: () -> Unit) {
204212 if (_connecting.value != null || container.reconnecting.value) return
205− val target = profiles.value.maxByOrNull { it.lastUsed }
206− ?: profiles.value.firstOrNull() ?: return
207− connect(target, onConnected)
213+ viewModelScope.launch {
214+ // Re-check after the suspending DataStore read: a concurrent connect (or the
215+ // cold-start auto-connect) may have started while we were waiting on profiles.
216+ if (_connecting.value != null || container.reconnecting.value) return@launch
217+ val all = runCatchingCancellable { container.profileStore.profiles.first() }
218+ .getOrDefault(emptyList())
219+ val target = all.maxByOrNull { it.lastUsed } ?: all.firstOrNull() ?: return@launch
220+ connect(target, onConnected)
221+ }
208222 }
209223
210224 /**
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+8−2
@@ -79,6 +79,7 @@ import androidx.compose.runtime.Composable
7979import androidx.compose.runtime.CompositionLocalProvider
8080import androidx.compose.runtime.LaunchedEffect
8181import androidx.compose.runtime.getValue
82+import androidx.compose.runtime.mutableIntStateOf
8283import androidx.compose.runtime.mutableStateOf
8384import androidx.compose.runtime.remember
8485import androidx.compose.runtime.saveable.rememberSaveable
@@ -910,8 +911,13 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
910911 // Windowed render: compose only the first `renderCap` rows, growing as the
911912 // user scrolls near the bottom. Bounds the LazyColumn's key+arrange work on a
912913 // huge list and keeps the tree-build + filter cost proportional to what's seen.
913− var renderCap by remember {
914− mutableStateOf(NetworkConfig.sessionListInitialPage)
914+ // rememberSaveable (not plain remember) so the window survives a rotation
915+ // alongside the LazyListState: with plain remember the list state restores the
916+ // scroll index (e.g. 200) while renderCap resets to the initial page (e.g. 50),
917+ // and the LazyColumn clamps to the last available item then re-grows the window
918+ // in a janky loop.
919+ var renderCap by rememberSaveable {
920+ mutableIntStateOf(NetworkConfig.sessionListInitialPage)
915921 }
916922 // Build the grouped entry list: a Pinned header (if any pinned sessions are
917923 // present), then date-grouped sections (Today / Yesterday / Last week / Older)
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+4−3
@@ -313,9 +313,10 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
313313 // Windowed render: compose only the first `renderCap` rows, growing as the
314314 // user scrolls near the bottom. Bounds composition work on a device that has
315315 // accumulated many crash reports (e.g. a flaky build used for weeks), matching
316− // the session/file lists' pattern.
317− var renderCap by remember {
318− mutableStateOf(NetworkConfig.diagnosticsListInitialPage)
316+ // the session/file lists' pattern. rememberSaveable (not plain remember) so the
317+ // window survives a rotation alongside the LazyListState — see SessionListScreen.
318+ var renderCap by rememberSaveable {
319+ mutableIntStateOf(NetworkConfig.diagnosticsListInitialPage)
319320 }
320321 val listState = androidx.compose.foundation.lazy.rememberLazyListState()
321322 androidx.compose.runtime.LaunchedEffect(listState, filtered.size) {
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+17−5
@@ -79,16 +79,28 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
7979
8080 /** Re-aggregate the cached fetch under the current time range, if a Ready report exists. */
8181 private suspend fun reaggregate() {
82+ // Snapshot the cached inputs and state together. Reading cachedInputs and _state.value
83+ // as two separate @Volatile reads is non-atomic: a concurrent doLoad() from a manual
84+ // reload can update cachedInputs and set State.Ready(newReport) in between, after which
85+ // reaggregate would pass the is-Ready check (now the new Ready) but aggregate the OLD
86+ // inputs — clobbering the user's fresh reload with stale totals. Capture both up front,
87+ // and re-check cachedInputs hasn't changed before publishing so a doLoad that landed
88+ // mid-aggregation wins instead.
89+ //
90+ // The is-Ready guard also serves the original purpose: a failed doLoad leaves state as
91+ // Error (with cachedInputs still populated from the prior successful fetch), and
92+ // reaggregating that stale cache would flip Error back to Ready — hiding the failure
93+ // and showing old totals as if the load succeeded. The user must retry (which clears
94+ // cachedInputs on its own success) to see fresh data again.
8295 val inputs = cachedInputs ?: return
83− // Only re-aggregate into Ready when we're already Ready: a failed doLoad leaves the
84− // state as Error (with cachedInputs still populated from the prior successful fetch),
85− // and reaggregating that stale cache would flip Error back to Ready — hiding the
86− // failure and showing old totals as if the load succeeded. The user must retry (which
87− // clears cachedInputs on its own success) to see fresh data again.
8896 if (_state.value !is State.Ready) return
8997 val cutoff = _timeRange.value.cutoffMs()
9098 withContext(Dispatchers.Default) {
9199 val report = aggregateUsage(inputs, cutoff)
100+ // Skip the publish if a doLoad replaced the cache while we were aggregating —
101+ // that doLoad set a fresher State.Ready, and overwriting it here would regress
102+ // the report to the pre-reload inputs under the (possibly stale) cutoff.
103+ if (cachedInputs !== inputs) return@withContext
92104 _state.value = State.Ready(report)
93105 }
94106 }