git.iko.soy/opencode-android
on master help sign in
Fix 7 bugs: streaming focus-highlight re-fire, jump-to-line beyond render cap, synthetic-key misses in removal/prune paths, elapsed timer 0:00 flash, McpViewModel add guard race
e66c7ediko
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+60−16
@@ -516,7 +516,10 @@ internal class MessageStore {
516 * Returns true if anything was removed. */
516 * Returns true if anything was removed. */
517 fun pruneStaleCacheSeeded(cacheSeededIds: Set<String>, rest: List<MessageWithParts>): Boolean {
517 fun pruneStaleCacheSeeded(cacheSeededIds: Set<String>, rest: List<MessageWithParts>): Boolean {
518 if (cacheSeededIds.isEmpty()) return false
518 if (cacheSeededIds.isEmpty()) return false
519+ // Build the retain-set from the synthetic key (matching the map's keying for
520+ // UnknownMessages with empty ids) so an entry stored under "unknown-c<created>" isn't
521+ // wrongly pruned just because its raw id ("") isn't in the REST snapshot's raw-id set.
519− val restIds = rest.mapTo(mutableSetOf()) { it.info.id }
522+ val restIds = rest.mapTo(mutableSetOf()) { syntheticKeyFor(it) }
520 var changed = false
523 var changed = false
521 for (id in cacheSeededIds) {
524 for (id in cacheSeededIds) {
522 if (id !in restIds && messages.remove(id) != null) changed = true
525 if (id !in restIds && messages.remove(id) != null) changed = true
@@ -536,6 +539,26 @@ internal class MessageStore {
536 m.info.id
539 m.info.id
537 }
540 }
538
541
542+ /** Resolve the actual map key for a raw message id from a server event. A non-empty id is
543+ * the map key as-is. An empty id ("") can't be the map key (handleMessageUpdated stores
544+ * UnknownMessages with empty ids under a synthetic "unknown-c<created>" / "unknown-h<hash>"
545+ * key), so search the map for the holder whose info is an UnknownMessage with an empty id.
546+ * Returns null when no holder matches — callers treat this as "not in the store" and skip.
547+ *
548+ * This is O(N) only for the rare empty-id case (UnknownMessages from unrecognized server
549+ * roles); the common non-empty-id path is an O(1) map lookup. Without this, handleMessageRemoved
550+ * / handlePartRemoved / the null-session MessagePartUpdated guard would all miss synthetic-keyed
551+ * holders, leaving ghost entries and dropped part removals. */
552+ private fun resolveMapKey(messageId: String): String? {
553+ if (messageId.isNotEmpty()) return messageId
554+ // Empty id: find a holder stored under a synthetic key whose info is an UnknownMessage
555+ // with an empty id. There's normally at most one such holder per session (an unrecognized
556+ // server role with no id is rare), so the linear scan is acceptable.
557+ return messages.entries.firstOrNull { entry ->
558+ entry.value.info is UnknownMessage && entry.value.info.id.isEmpty()
559+ }?.key
560+ }
561+
539 /** Reorder the message map by message creation time (stable). Ties and entries with no
562 /** Reorder the message map by message creation time (stable). Ties and entries with no
540 * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
563 * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
541 * relative order, so such a message stays last. Preserves each entry's map key so the
564 * relative order, so such a message stays last. Preserves each entry's map key so the
@@ -562,18 +585,30 @@ internal class MessageStore {
562
585
563 is MessagePartUpdated -> {
586 is MessagePartUpdated -> {
564 val part = event.properties.part
587 val part = event.properties.part
565− val messageId = part.messageID ?: event.properties.messageID
588+ val rawMessageId = part.messageID ?: event.properties.messageID
566 val partSession = part.sessionID ?: event.properties.sessionID
589 val partSession = part.sessionID ?: event.properties.sessionID
567 when {
590 when {
568− messageId == null -> false
591+ rawMessageId == null -> false
592+ // Resolve the map key: an UnknownMessage with an empty id is stored under a
593+ // synthetic key, so upsertPart(rawMessageId, part) would miss it and fork a
594+ // duplicate holder under key "". resolveMapKey returns the synthetic key when
595+ // the holder exists, or the raw id (possibly "") when it doesn't — in which
596+ // case upsertPart creates a fresh holder under that id, matching the prior
597+ // behavior for a genuinely new message.
598+ else -> {
599+ val mapKey = resolveMapKey(rawMessageId) ?: rawMessageId
600+ if (partSession != null) {
569− partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
601+ if (partSession == sessionId) upsertPart(mapKey, part) else false
602+ } else {
570− // No session id anywhere on the event: a null session is NOT a wildcard
603+ // No session id anywhere on the event: a null session is NOT a wildcard
571− // (mirroring isIdle/isError). Every open session's store sees the shared
604+ // (mirroring isIdle/isError). Every open session's store sees the shared
572− // event stream, so accepting it unconditionally would leak the part into
605+ // event stream, so accepting it unconditionally would leak the part into
573− // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
606+ // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
574− // only if this store already holds the parent message — MessageUpdated,
607+ // only if this store already holds the parent message — MessageUpdated,
575− // which always carries a session id, will have created it here first.
608+ // which always carries a session id, will have created it here first.
576− else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
609+ if (messages.containsKey(mapKey)) upsertPart(mapKey, part) else false
610+ }
611+ }
577 }
612 }
578 }
613 }
579
614
@@ -619,21 +654,30 @@ internal class MessageStore {
619 }
654 }
620
655
621 private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
656 private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
622− val messageId = event.properties.messageID ?: return false
657+ val rawMessageId = event.properties.messageID ?: return false
623 val partId = event.properties.partID ?: return false
658 val partId = event.properties.partID ?: return false
624 val eventSession = event.properties.sessionID
659 val eventSession = event.properties.sessionID
660+ // Resolve the actual map key: an UnknownMessage with an empty id is stored under a
661+ // synthetic key, so a bare messages.containsKey(rawMessageId) would miss it and the
662+ // part removal would be silently dropped.
663+ val mapKey = resolveMapKey(rawMessageId) ?: return false
625 when {
664 when {
626 eventSession != null && eventSession != sessionId -> return false
665 eventSession != null && eventSession != sessionId -> return false
627 // Null session id: only act if this store already holds the parent message, so a
666 // Null session id: only act if this store already holds the parent message, so a
628 // null-session part removal can't drop a part from an unrelated session's store.
667 // null-session part removal can't drop a part from an unrelated session's store.
629− eventSession == null && !messages.containsKey(messageId) -> return false
668+ eventSession == null && !messages.containsKey(mapKey) -> return false
630 }
669 }
631− return removePart(messageId, partId)
670+ return removePart(mapKey, partId)
632 }
671 }
633
672
634 private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
673 private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
635− val id = event.properties.messageID ?: return false
674+ val rawId = event.properties.messageID ?: return false
636 val eventSession = event.properties.sessionID
675 val eventSession = event.properties.sessionID
676+ // Resolve the actual map key: an UnknownMessage with an empty id is stored under a
677+ // synthetic key ("unknown-c<created>" / "unknown-h<hash>"), so messages.remove(rawId)
678+ // would miss it and leave the ghost entry stranded — the pivotKey cleanup below would
679+ // be dead code (the early return fires before it runs).
680+ val id = resolveMapKey(rawId) ?: return false
637 when {
681 when {
638 eventSession != null && eventSession != sessionId -> return false
682 eventSession != null && eventSession != sessionId -> return false
639 // Null session id: only act if this store already holds the message (message ids
683 // Null session id: only act if this store already holds the message (message ids
@@ -648,7 +692,7 @@ internal class MessageStore {
648 // impact today — seed() only consults it when the holder exists — but unbounded growth
692 // impact today — seed() only consults it when the holder exists — but unbounded growth
649 // across many add/remove cycles in a long-lived session). Mirrors evictOldMessages,
693 // across many add/remove cycles in a long-lived session). Mirrors evictOldMessages,
650 // which already uses the map key (oldestKey) for its pivot cleanup.
694 // which already uses the map key (oldestKey) for its pivot cleanup.
651− val pivotKey = if (id.isEmpty() && removed.info is UnknownMessage) {
695+ val pivotKey = if (rawId.isEmpty() && removed.info is UnknownMessage) {
652 removed.info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${removed.info.hashCode()}"
696 removed.info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${removed.info.hashCode()}"
653 } else {
697 } else {
654 id
698 id
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+27−7
@@ -157,6 +157,7 @@ import kotlinx.coroutines.coroutineScope
157import kotlinx.coroutines.delay
157import kotlinx.coroutines.delay
158import kotlinx.coroutines.flow.collectLatest
158import kotlinx.coroutines.flow.collectLatest
159import kotlinx.coroutines.flow.distinctUntilChanged
159import kotlinx.coroutines.flow.distinctUntilChanged
160+import kotlinx.coroutines.flow.first
160import kotlinx.coroutines.launch
161import kotlinx.coroutines.launch
161import kotlinx.coroutines.withContext
162import kotlinx.coroutines.withContext
162import soy.iko.opencode.data.model.Part
163import soy.iko.opencode.data.model.Part
@@ -1118,14 +1119,25 @@ fun ChatScreen(
1118 // focus id is threaded through the chat route from GlobalSearchScreen. The highlight
1119 // focus id is threaded through the chat route from GlobalSearchScreen. The highlight
1119 // clears itself after a short delay so the user can re-read the surrounding context
1120 // clears itself after a short delay so the user can re-read the surrounding context
1120 // without a persistent marker. Runs once per focusMessageId (cleared on consume).
1121 // without a persistent marker. Runs once per focusMessageId (cleared on consume).
1122+ //
1123+ // Keyed ONLY on focusedMessageId (not listItems): a bare listItems key re-keys on every
1124+ // streamed token (the list is rebuilt per snapshot), which cancels the 2.5s delay before
1125+ // it can clear the highlight — re-scrolling to the focus on every token and making an
1126+ // actively-streaming chat unreadable. snapshotFlow waits for the message to appear in the
1127+ // list (it may not be present on the first composition while messages load) without
1128+ // re-keying the effect on every list change.
1121 var focusedMessageId by remember(focusMessageId) { mutableStateOf(focusMessageId) }
1129 var focusedMessageId by remember(focusMessageId) { mutableStateOf(focusMessageId) }
1130+ val currentListItemsForFocus by rememberUpdatedState(listItems)
1122− LaunchedEffect(focusedMessageId, listItems) {
1131+ LaunchedEffect(focusedMessageId) {
1123 val focus = focusedMessageId ?: return@LaunchedEffect
1132 val focus = focusedMessageId ?: return@LaunchedEffect
1124− if (listItems.isEmpty()) return@LaunchedEffect
1133+ // Wait for the focused message to appear in the list, then resolve its index. A
1125− val index = listItems.indexOfFirst {
1134+ // first-composition empty list (messages still loading) will populate as the seed
1135+ // arrives; snapshotFlow re-evaluates on each list change until the match is found.
1136+ val index = snapshotFlow {
1137+ currentListItemsForFocus.indexOfFirst {
1126− it is MessageListItem.Message && it.message.info.id == focus
1138+ it is MessageListItem.Message && it.message.info.id == focus
1127− }
1139+ }
1128− if (index < 0) return@LaunchedEffect
1140+ }.first { it >= 0 }
1129 runCatchingCancellable { listState.animateScrollToItem(index) }
1141 runCatchingCancellable { listState.animateScrollToItem(index) }
1130 kotlinx.coroutines.delay(2500)
1142 kotlinx.coroutines.delay(2500)
1131 focusedMessageId = null
1143 focusedMessageId = null
@@ -1711,7 +1723,15 @@ fun ChatScreen(
1711 // user can see how long the run lasted before they stopped it, rather
1723 // user can see how long the run lasted before they stopped it, rather
1712 // than the clock continuing to tick until SessionIdle clears _running.
1724 // than the clock continuing to tick until SessionIdle clears _running.
1713 val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1725 val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1726+ // Seed the initial value synchronously from startMs so the timer
1727+ // doesn't flash 0:00 when the __typing row is recycled by LazyColumn
1728+ // (scrolled out then back in): produceState's coroutine is cancelled on
1729+ // disposal and re-launched on re-entry, and a 0L initialValue would
1730+ // briefly show "0:00" before the producer's first iteration runs.
1714− val elapsedMs by produceState(0L, startMs, aborting) {
1731+ val elapsedMs by produceState(
1732+ initialValue = if (startMs > 0L) (System.currentTimeMillis() - startMs).coerceAtLeast(0L) else 0L,
1733+ startMs, aborting,
1734+ ) {
1715 if (startMs == 0L) return@produceState
1735 if (startMs == 0L) return@produceState
1716 if (aborting) {
1736 if (aborting) {
1717 // Freeze at the last-computed value: one final snapshot then stop.
1737 // Freeze at the last-computed value: one final snapshot then stop.
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+19−1
@@ -202,8 +202,15 @@ fun FileViewScreen(
202 initialLine = initialLine,
202 initialLine = initialLine,
203 content = state.content,
203 content = state.content,
204 isBinary = state.content?.isBinary == true,
204 isBinary = state.content?.isBinary == true,
205− lineCount = lines.size,
205+ lineCount = allLines.size,
206 listState = listState,
206 listState = listState,
207+ onExpandRender = { targetLine ->
208+ // Expand renderLimit so the LazyColumn actually has the target item to scroll to.
209+ // Mirrors goToLine()'s expansion: round up to the next MAX_RENDERED_LINES boundary.
210+ if (targetLine >= renderLimit) {
211+ renderLimit = (targetLine + MAX_RENDERED_LINES).coerceAtMost(allLines.size)
212+ }
213+ },
207 )
214 )
208 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
215 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
209 // the choice, and applied to the off-thread match scan below.
216 // the choice, and applied to the off-thread match scan below.
@@ -551,6 +558,7 @@ private fun rememberJumpToLineHighlight(
551 isBinary: Boolean,
558 isBinary: Boolean,
552 lineCount: Int,
559 lineCount: Int,
553 listState: LazyListState,
560 listState: LazyListState,
561+ onExpandRender: (Int) -> Unit = {},
554): MutableState<Int?> {
562): MutableState<Int?> {
555 val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
563 val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
556 var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
564 var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
@@ -558,6 +566,16 @@ private fun rememberJumpToLineHighlight(
558 val line = initialLine ?: return@LaunchedEffect
566 val line = initialLine ?: return@LaunchedEffect
559 if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
567 if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
560 val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
568 val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
569+ // If the target is beyond the current render cap, expand the render limit before
570+ // scrolling so the LazyColumn actually has that item — otherwise scrollToItem would
571+ // clamp to the last rendered line and the user would land on the wrong line. Mirrors
572+ // goToLine()'s expansion. A short delay lets the state change trigger recomposition
573+ // and the LazyColumn measure the expanded item count before the scroll runs; without
574+ // this the scroll still sees the old (capped) item count and clamps to the last
575+ // rendered line. The delay is harmless when no expansion was needed (onExpandRender
576+ // is a no-op in that case).
577+ onExpandRender(target)
578+ kotlinx.coroutines.delay(1)
561 runCatchingCancellable { listState.scrollToItem(target) }
579 runCatchingCancellable { listState.scrollToItem(target) }
562 highlight.value = target
580 highlight.value = target
563 didInitialScroll = true
581 didInitialScroll = true
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+5−1
@@ -123,8 +123,12 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
123 val cleanTarget = target.trim()
123 val cleanTarget = target.trim()
124 if (cleanName.isEmpty() || cleanTarget.isEmpty()) return
124 if (cleanName.isEmpty() || cleanTarget.isEmpty()) return
125 val config = buildMcpConfig(kind, cleanTarget, env)
125 val config = buildMcpConfig(kind, cleanTarget, env)
126+ // Set the guard synchronously before launching, so a second invocation can't pass the
127+ // guard while the launched coroutine hasn't run yet. viewModelScope uses Main.immediate
128+ // (so the body runs immediately on the main thread today), but setting it here is
129+ // robust against a future dispatcher change and matches ServerListViewModel's pattern.
130+ _adding.value = true
126 viewModelScope.launch {
131 viewModelScope.launch {
127− _adding.value = true
128 val ok = runCatchingCancellable { conn.api.addMcp(cleanName, config) }.isSuccess
132 val ok = runCatchingCancellable { conn.api.addMcp(cleanName, config) }.isSuccess
129 _adding.value = false
133 _adding.value = false
130 if (ok) {
134 if (ok) {