git.iko.soy/opencode-android
on master help sign in
Fix eight bugs across data, viewmodel, and UI layers
8a50d4diko
app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt+9−5
@@ -85,13 +85,17 @@ open class AttachmentDraftStore private constructor(
85 // and silently drops the staged attachments on reload.
85 // and silently drops the staged attachments on reload.
86 val encoded = json.encodeToString(attachments)
86 val encoded = json.encodeToString(attachments)
87 val tmp = File(file.parentFile, file.name + ".tmp")
87 val tmp = File(file.parentFile, file.name + ".tmp")
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 {
88− tmp.writeText(encoded)
93+ tmp.writeText(encoded)
89− if (!tmp.renameTo(file)) {
94+ if (!tmp.renameTo(file)) {
90− try {
91 file.writeText(encoded)
95 file.writeText(encoded)
92− } finally {
93− tmp.delete()
94 }
96 }
97+ } finally {
98+ tmp.delete()
95 }
99 }
96 }
100 }
97 }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
101 }.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(
99 // wiping the cached conversation on the next open.
99 // wiping the cached conversation on the next open.
100 val encoded = OpencodeJson.encodeToString(serializer, messages)
100 val encoded = OpencodeJson.encodeToString(serializer, messages)
101 val tmp = File(file.parentFile, file.name + ".tmp")
101 val tmp = File(file.parentFile, file.name + ".tmp")
102− tmp.writeText(encoded)
102+ // One try/finally covers BOTH write paths: a throw from the initial
103− if (!tmp.renameTo(file)) {
104− // try/finally so a throw from file.writeText (disk full, permission) still
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− // cleans up tmp — otherwise *.tmp files accumulate across failed writes.
105+ // files across failed first writes.
106− try {
106+ try {
107+ tmp.writeText(encoded)
108+ if (!tmp.renameTo(file)) {
107 file.writeText(encoded)
109 file.writeText(encoded)
108− } finally {
109− tmp.delete()
110 }
110 }
111+ } finally {
112+ tmp.delete()
111 }
113 }
112 }
114 }
113 }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
115 }.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(
156 // decode and load() would then drop ALL queued offline messages.
156 // decode and load() would then drop ALL queued offline messages.
157 val encoded = json.encodeToString(list)
157 val encoded = json.encodeToString(list)
158 val tmp = File(f.parentFile, f.name + ".tmp")
158 val tmp = File(f.parentFile, f.name + ".tmp")
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 {
159− tmp.writeText(encoded)
164+ tmp.writeText(encoded)
160− if (!tmp.renameTo(f)) {
165+ if (!tmp.renameTo(f)) {
161− try {
162 f.writeText(encoded)
166 f.writeText(encoded)
163− } finally {
164− tmp.delete()
165 }
167 }
168+ } finally {
169+ tmp.delete()
166 }
170 }
167 }
171 }
168 }.onFailure { Log.w("OutboxStore", "Failed to persist outbox", it) }
172 }.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 {
610 // behavior for a genuinely new message.
610 // behavior for a genuinely new message.
611 else -> {
611 else -> {
612 val mapKey = resolveMapKey(rawMessageId) ?: rawMessageId
612 val mapKey = resolveMapKey(rawMessageId) ?: rawMessageId
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
613− if (partSession != null) {
629+ } else if (partSession != null) {
614 if (partSession == sessionId) upsertPart(mapKey, part) else false
630 if (partSession == sessionId) upsertPart(mapKey, part) else false
615 } else {
631 } else {
616 // No session id anywhere on the event: a null session is NOT a wildcard
632 // No session id anywhere on the event: a null session is NOT a wildcard
@@ -656,7 +672,11 @@ internal class MessageStore {
656 // A MessageUpdated re-introduces the message into the store (it was either evicted
672 // A MessageUpdated re-introduces the message into the store (it was either evicted
657 // by evictOldMessages or genuinely new). Drop any eviction record so a following
673 // by evictOldMessages or genuinely new). Drop any eviction record so a following
658 // MessagePartUpdated updates this holder instead of being dropped as "late".
674 // 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.
659 info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
678 info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
679+ if (info.id.isEmpty()) evictedMessageIds.remove(key)
660 if (existing == null) {
680 if (existing == null) {
661 messages[key] = Holder(info, LinkedHashMap())
681 messages[key] = Holder(info, LinkedHashMap())
662 } else {
682 } else {
@@ -705,6 +725,9 @@ internal class MessageStore {
705 // The message is gone from the store; drop any eviction record for it so the set
725 // The message is gone from the store; drop any eviction record for it so the set
706 // doesn't retain ids of long-deleted messages. (A re-seed would also clear this,
726 // doesn't retain ids of long-deleted messages. (A re-seed would also clear this,
707 // but a quiet session that never reconnects could otherwise accumulate entries.)
727 // 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.
708 removed.info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
731 removed.info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
709 // Derive the same key handleMessageUpdated would have stored this holder under. For an
732 // Derive the same key handleMessageUpdated would have stored this holder under. For an
710 // UnknownMessage with an empty id, that's the synthetic "unknown-c<created>" /
733 // UnknownMessage with an empty id, that's the synthetic "unknown-c<created>" /
@@ -718,6 +741,7 @@ internal class MessageStore {
718 } else {
741 } else {
719 id
742 id
720 }
743 }
744+ if (removed.info.id.isEmpty()) evictedMessageIds.remove(pivotKey)
721 messageInfoUpdatedSincePivot.remove(pivotKey)
745 messageInfoUpdatedSincePivot.remove(pivotKey)
722 removed.parts.keys.forEach { streamedSincePivot.remove(it) }
746 removed.parts.keys.forEach { streamedSincePivot.remove(it) }
723 return true
747 return true
@@ -786,10 +810,19 @@ internal class MessageStore {
786 // Record the evicted message's id so a late MessagePartUpdated for it is dropped
810 // Record the evicted message's id so a late MessagePartUpdated for it is dropped
787 // instead of re-inserting a single-part bubble (see upsertPart). Also clear any
811 // instead of re-inserting a single-part bubble (see upsertPart). Also clear any
788 // prior eviction record for this id from an older cycle (the message re-entered
812 // 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.
790 removed?.info?.id?.takeIf { it.isNotEmpty() }?.let { id ->
820 removed?.info?.id?.takeIf { it.isNotEmpty() }?.let { id ->
791 evictedMessageIds.add(id)
821 evictedMessageIds.add(id)
792 }
822 }
823+ if (removed != null && removed.info.id.isEmpty()) {
824+ evictedMessageIds.add(oldestKey)
825+ }
793 // Prune the evicted message's pivot bookkeeping so these sets don't grow
826 // Prune the evicted message's pivot bookkeeping so these sets don't grow
794 // unbounded over a long-running session that never reconnects (they're only
827 // unbounded over a long-running session that never reconnects (they're only
795 // otherwise cleared on reseed/seed).
828 // 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(
1254 synchronized(activeRuns) {
1254 synchronized(activeRuns) {
1255 activeRuns.clear()
1255 activeRuns.clear()
1256 publishRunState()
1256 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
1257 }
1261 }
1258 if (previousProfileId != profile.id) {
1262 if (previousProfileId != profile.id) {
1259 _unread.value = emptyMap()
1263 _unread.value = emptyMap()
@@ -1298,6 +1302,10 @@ open class AppContainer private constructor(
1298 synchronized(activeRuns) {
1302 synchronized(activeRuns) {
1299 activeRuns.clear()
1303 activeRuns.clear()
1300 publishRunState()
1304 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
1301 }
1309 }
1302 _unread.value = emptyMap()
1310 _unread.value = emptyMap()
1303 unreadMessageIds.clear()
1311 unreadMessageIds.clear()
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+20−16
@@ -1528,28 +1528,32 @@ fun ChatScreen(
1528 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1528 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1529 ?.let { resolveModelLabel(it, models) }
1529 ?.let { resolveModelLabel(it, models) }
1530 }
1530 }
1531− // Key agentLabel/quoteText on the message id (not message.parts) so
1531+ // Key agentLabel/quoteText on the message id + the id of the
1532− // the streaming message — whose `parts` list is replaced on every
1532+ // last TextPart/AgentPart, so the streaming message's label
1533− // token — doesn't re-run filterIsInstance per token for these two
1533+ // updates when a NEW text/agent part is appended (its id
1534− // derived labels. For a non-streaming message, `message.parts` is
1534+ // changes) without re-running filterIsInstance per token (a
1535− // reference-stable anyway, so this is equivalent. For the streaming
1535+ // streaming TextPart's id is stable across token updates, so
1536− // message the label updates as parts change via the message.info/
1536+ // the key only changes on append, not on every token). The
1537− // parts reference change below — keyed on messageId + a parts-hash
1537+ // prior `remember(messageId)` pinned these at first
1538− // is overkill; agentLabel/quoteText only need to reflect the latest
1538+ // composition: for a message that started with reasoning/tool
1539− // parts, and `remember(messageId, message.parts)` would re-key per
1539+ // parts before any TextPart, quoteText stayed "" for the life
1540− // token. Instead key on `messageId` alone and accept that the label
1540+ // of the list item, so swipe-to-reply yielded "cannot quote
1541− // lags by one recomposition for the streaming bubble (it stabilizes
1541+ // this" even after text arrived.
1542− // once the stream finishes). This is invisible in practice: agent
1542+ val lastTextPartId = remember(message.parts) {
1543− // label is set on the first part, quote text grows with text parts
1543+ message.parts.lastOrNull { it is TextPart }?.id
1544− // but is only consumed on a swipe (user-initiated, post-stream).
1544+ }
1545+ val lastAgentPartId = remember(message.parts) {
1546+ message.parts.filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1547+ .lastOrNull { it.name.isNotBlank() }?.id
1548+ }
1545− val agentLabel = remember(messageId) {
1549+ val agentLabel = remember(messageId, lastAgentPartId) {
1546 message.parts
1550 message.parts
1547 .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1551 .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1548 .firstOrNull { it.name.isNotBlank() }?.name
1552 .firstOrNull { it.name.isNotBlank() }?.name
1549 }
1553 }
1550 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1554 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1551 // image/code-only messages, which then opt out of the swipe gesture.
1555 // image/code-only messages, which then opt out of the swipe gesture.
1552− val quoteText = remember(messageId) {
1556+ val quoteText = remember(messageId, lastTextPartId) {
1553 message.parts
1557 message.parts
1554 .filterIsInstance<TextPart>()
1558 .filterIsInstance<TextPart>()
1555 .joinToString("\n\n") { it.text }
1559 .joinToString("\n\n") { it.text }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+17−6
@@ -1192,10 +1192,14 @@ class ChatViewModel(
1192 // server switch between send() returning and this coroutine starting would send the
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
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
1194 // delivered to the correct server on reconnect, rather than failing against a stale
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.
1195− // connection. Reset _running (set by beginRun above) since no SSE streaming will
1199+ // 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.
1200 // follow an outbox enqueue — without this the run indicator would be stuck on.
1197 if (container.activeConnection.value !== conn) {
1201 if (container.activeConnection.value !== conn) {
1198− enqueueOffline(trimmed, attachments)
1202+ enqueueOffline(trimmed, attachments, profileId = conn.profile.id)
1199 _running.value = false
1203 _running.value = false
1200 return@launch
1204 return@launch
1201 }
1205 }
@@ -1259,8 +1263,14 @@ class ChatViewModel(
1259 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
1263 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
1260 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
1264 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
1261 * are restored so nothing is lost.
1265 * 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.
1262 */
1272 */
1263− private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
1273+ private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>, profileId: String? = null): Boolean {
1264 _failedDraft.value = null
1274 _failedDraft.value = null
1265 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1275 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1266 val clearDraft = _draft.value.trim() == trimmed
1276 val clearDraft = _draft.value.trim() == trimmed
@@ -1269,9 +1279,10 @@ class ChatViewModel(
1269 _draft.value = ""
1279 _draft.value = ""
1270 }
1280 }
1271 viewModelScope.launch {
1281 viewModelScope.launch {
1282+ val resolvedProfileId = profileId
1272− val profileId = connection?.profile?.id
1283+ ?: connection?.profile?.id
1273 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1284 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1274− if (profileId == null) {
1285+ if (resolvedProfileId == null) {
1275 // No server to attribute the message to — restore the composer and report it
1286 // No server to attribute the message to — restore the composer and report it
1276 // rather than silently dropping the prompt.
1287 // rather than silently dropping the prompt.
1277 suppressDraftPersist.set(false)
1288 suppressDraftPersist.set(false)
@@ -1285,7 +1296,7 @@ class ChatViewModel(
1285 container.outboxStore.enqueue(
1296 container.outboxStore.enqueue(
1286 OutboxMessage(
1297 OutboxMessage(
1287 id = java.util.UUID.randomUUID().toString(),
1298 id = java.util.UUID.randomUUID().toString(),
1288− profileId = profileId,
1299+ profileId = resolvedProfileId,
1289 sessionId = sessionId,
1300 sessionId = sessionId,
1290 text = trimmed,
1301 text = trimmed,
1291 attachments = attachments.map { it.toPersisted() },
1302 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
61import androidx.compose.runtime.Composable
61import androidx.compose.runtime.Composable
62import androidx.compose.runtime.LaunchedEffect
62import androidx.compose.runtime.LaunchedEffect
63import androidx.compose.runtime.getValue
63import androidx.compose.runtime.getValue
64+import androidx.compose.runtime.mutableIntStateOf
64import androidx.compose.runtime.mutableStateOf
65import androidx.compose.runtime.mutableStateOf
65import androidx.compose.runtime.remember
66import androidx.compose.runtime.remember
66import androidx.compose.runtime.rememberCoroutineScope
67import androidx.compose.runtime.rememberCoroutineScope
@@ -718,7 +719,9 @@ private fun DirectoryListing(
718 // so the order is correct — only the composed slice is capped). Mirrors the
719 // so the order is correct — only the composed slice is capped). Mirrors the
719 // session list's windowed render pattern. Hoisted above the LazyColumn because
720 // session list's windowed render pattern. Hoisted above the LazyColumn because
720 // state reads must be in a @Composable scope (LazyListScope is not one).
721 // state reads must be in a @Composable scope (LazyListScope is not one).
722+ // rememberSaveable (not plain remember) so the window survives a rotation alongside
723+ // the LazyListState — see SessionListScreen for the clamp-and-regrow jank otherwise.
721− var renderCap by remember { mutableStateOf(NetworkConfig.fileListInitialPage) }
724+ var renderCap by rememberSaveable { mutableIntStateOf(NetworkConfig.fileListInitialPage) }
722 val visibleSorted = remember(sorted, renderCap) {
725 val visibleSorted = remember(sorted, renderCap) {
723 if (sorted.size <= renderCap) sorted else sorted.take(renderCap)
726 if (sorted.size <= renderCap) sorted else sorted.take(renderCap)
724 }
727 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+18−4
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.asSharedFlow
17import kotlinx.coroutines.flow.asSharedFlow
18import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.asStateFlow
19import kotlinx.coroutines.flow.combine
19import kotlinx.coroutines.flow.combine
20+import kotlinx.coroutines.flow.first
20import kotlinx.coroutines.flow.map
21import kotlinx.coroutines.flow.map
21import kotlinx.coroutines.flow.stateIn
22import kotlinx.coroutines.flow.stateIn
22import kotlinx.coroutines.flow.update
23import kotlinx.coroutines.flow.update
@@ -199,12 +200,25 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
199 * the server list. Gives the user a familiar gesture to retry a failed auto-connect
200 * the server list. Gives the user a familiar gesture to retry a failed auto-connect
200 * instead of having to tap a server card. Targets the profile with the highest
201 * instead of having to tap a server card. Targets the profile with the highest
201 * lastUsed timestamp (the most natural "retry the one I was just on"), falling back
202 * 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. */
203 fun refresh(onConnected: () -> Unit) {
211 fun refresh(onConnected: () -> Unit) {
204 if (_connecting.value != null || container.reconnecting.value) return
212 if (_connecting.value != null || container.reconnecting.value) return
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())
205− val target = profiles.value.maxByOrNull { it.lastUsed }
219+ val target = all.maxByOrNull { it.lastUsed } ?: all.firstOrNull() ?: return@launch
206− ?: profiles.value.firstOrNull() ?: return
207− connect(target, onConnected)
220+ connect(target, onConnected)
221+ }
208 }
222 }
209
223
210 /**
224 /**
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+8−2
@@ -79,6 +79,7 @@ import androidx.compose.runtime.Composable
79import androidx.compose.runtime.CompositionLocalProvider
79import androidx.compose.runtime.CompositionLocalProvider
80import androidx.compose.runtime.LaunchedEffect
80import androidx.compose.runtime.LaunchedEffect
81import androidx.compose.runtime.getValue
81import androidx.compose.runtime.getValue
82+import androidx.compose.runtime.mutableIntStateOf
82import androidx.compose.runtime.mutableStateOf
83import androidx.compose.runtime.mutableStateOf
83import androidx.compose.runtime.remember
84import androidx.compose.runtime.remember
84import androidx.compose.runtime.saveable.rememberSaveable
85import androidx.compose.runtime.saveable.rememberSaveable
@@ -910,8 +911,13 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
910 // Windowed render: compose only the first `renderCap` rows, growing as the
911 // Windowed render: compose only the first `renderCap` rows, growing as the
911 // user scrolls near the bottom. Bounds the LazyColumn's key+arrange work on a
912 // user scrolls near the bottom. Bounds the LazyColumn's key+arrange work on a
912 // huge list and keeps the tree-build + filter cost proportional to what's seen.
913 // huge list and keeps the tree-build + filter cost proportional to what's seen.
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.
913− var renderCap by remember {
919+ var renderCap by rememberSaveable {
914− mutableStateOf(NetworkConfig.sessionListInitialPage)
920+ mutableIntStateOf(NetworkConfig.sessionListInitialPage)
915 }
921 }
916 // Build the grouped entry list: a Pinned header (if any pinned sessions are
922 // Build the grouped entry list: a Pinned header (if any pinned sessions are
917 // present), then date-grouped sections (Today / Yesterday / Last week / Older)
923 // 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) {
313 // Windowed render: compose only the first `renderCap` rows, growing as the
313 // Windowed render: compose only the first `renderCap` rows, growing as the
314 // user scrolls near the bottom. Bounds composition work on a device that has
314 // user scrolls near the bottom. Bounds composition work on a device that has
315 // accumulated many crash reports (e.g. a flaky build used for weeks), matching
315 // accumulated many crash reports (e.g. a flaky build used for weeks), matching
316− // the session/file lists' pattern.
316+ // the session/file lists' pattern. rememberSaveable (not plain remember) so the
317+ // window survives a rotation alongside the LazyListState — see SessionListScreen.
317− var renderCap by remember {
318+ var renderCap by rememberSaveable {
318− mutableStateOf(NetworkConfig.diagnosticsListInitialPage)
319+ mutableIntStateOf(NetworkConfig.diagnosticsListInitialPage)
319 }
320 }
320 val listState = androidx.compose.foundation.lazy.rememberLazyListState()
321 val listState = androidx.compose.foundation.lazy.rememberLazyListState()
321 androidx.compose.runtime.LaunchedEffect(listState, filtered.size) {
322 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() {
79
79
80 /** Re-aggregate the cached fetch under the current time range, if a Ready report exists. */
80 /** Re-aggregate the cached fetch under the current time range, if a Ready report exists. */
81 private suspend fun reaggregate() {
81 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.
82 val inputs = cachedInputs ?: return
95 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.
88 if (_state.value !is State.Ready) return
96 if (_state.value !is State.Ready) return
89 val cutoff = _timeRange.value.cutoffMs()
97 val cutoff = _timeRange.value.cutoffMs()
90 withContext(Dispatchers.Default) {
98 withContext(Dispatchers.Default) {
91 val report = aggregateUsage(inputs, cutoff)
99 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
92 _state.value = State.Ready(report)
104 _state.value = State.Ready(report)
93 }
105 }
94 }
106 }