git.iko.soy/opencode-android
on master help sign in
Fix 10 bugs across chat, usage, MCP, diff, and a11y layers
be8d102iko
app/src/main/java/soy/iko/opencode/data/model/Todo.kt+1−1
@@ -35,7 +35,7 @@ fun TodoItem.priorityEnum(): TodoPriority = when ((priority ?: "").trim().lowerc
35 "high", "h", "1", "urgent", "critical" -> TodoPriority.HIGH
35 "high", "h", "1", "urgent", "critical" -> TodoPriority.HIGH
36 "medium", "med", "m", "normal", "2" -> TodoPriority.MEDIUM
36 "medium", "med", "m", "normal", "2" -> TodoPriority.MEDIUM
37 "low", "l", "3", "minor", "backlog" -> TodoPriority.LOW
37 "low", "l", "3", "minor", "backlog" -> TodoPriority.LOW
38− else -> if (priority.isNullOrBlank()) TodoPriority.NONE else TodoPriority.NONE
38+ else -> TodoPriority.NONE
39}
39}
40
40
41fun TodoItem.statusEnum(): TodoStatus = when (status.trim().lowercase()) {
41fun TodoItem.statusEnum(): TodoStatus = when (status.trim().lowercase()) {
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+37−3
@@ -1479,10 +1479,20 @@ class ChatViewModel(
1479 fun continueRun(@Suppress("UNUSED_PARAMETER") assistantMessageId: String) {
1479 fun continueRun(@Suppress("UNUSED_PARAMETER") assistantMessageId: String) {
1480 val conn = connection ?: return
1480 val conn = connection ?: return
1481 if (running.value || aborting.value) return
1481 if (running.value || aborting.value) return
1482+ if (!beginRun()) return
1483+ // Reopen the relight gate so this run's streamed parts keep the indicator lit (and a
1484+ // later SessionIdle re-closes it). Without this, runEndedByIdle stays true from the
1485+ // previous run's SessionIdle and the relight guard at the event collector would suppress
1486+ // every streamed part of the continuation, leaving the working indicator / Stop button /
1487+ // foreground service off for the whole run.
1488+ runEndedByIdle = false
1482 viewModelScope.launch {
1489 viewModelScope.launch {
1483 runCatchingCancellable {
1490 runCatchingCancellable {
1484 conn.api.sendPrompt(sessionId, text = "continue")
1491 conn.api.sendPrompt(sessionId, text = "continue")
1492+ }.onFailure {
1493+ _running.value = false
1485− }.onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1494+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1495+ }
1486 }
1496 }
1487 }
1497 }
1488
1498
@@ -1492,10 +1502,23 @@ class ChatViewModel(
1492 * effectively replacing the edited message; the reverted banner's Undo restores it.
1502 * effectively replacing the edited message; the reverted banner's Undo restores it.
1493 * Overwrites the current draft — this is an explicit edit action on that message. */
1503 * Overwrites the current draft — this is an explicit edit action on that message. */
1494 fun editMessage(messageId: String, text: String) {
1504 fun editMessage(messageId: String, text: String) {
1495− revertTo(messageId)
1505+ val conn = connection ?: return
1496 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1506 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1497 _pendingQuote.value = null
1507 _pendingQuote.value = null
1498− _editing.value = true
1508+ // Do NOT delegate to revertTo(): revertTo's onSuccess sets _editing = false, which races
1509+ // this synchronous _editing = true (the async revert completes after editMessage returns
1510+ // and clobbers the banner). Inline the revert so editMessage owns the _editing lifecycle:
1511+ // set it true on success, matching the documented contract ("Cleared on unrevert and on
1512+ // send"). revertTo() (the manual revert button) still clears _editing itself.
1513+ viewModelScope.launch {
1514+ runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1515+ .onSuccess {
1516+ _reverted.value = it.isReverted
1517+ _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1518+ _editing.value = true
1519+ }
1520+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1521+ }
1499 }
1522 }
1500
1523
1501 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1524 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
@@ -1745,6 +1768,17 @@ class ChatViewModel(
1745 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1768 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1746 .onFailure {
1769 .onFailure {
1747 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1770 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1771+ // Revoke the session-scoped grant on failure: enqueuePermission() auto-responds
1772+ // when (type, pattern) is in sessionAllowed, so leaving the entry in would loop
1773+ // respondPermission -> fail -> enqueuePermission -> respondPermission ... forever,
1774+ // spamming the dead server and the error snackbar without ever re-prompting the
1775+ // user. Removing it makes enqueuePermission fall through to re-queueing the
1776+ // request for the dialog so the user can retry.
1777+ if (response == PermissionResponse.SESSION) {
1778+ val type = permission.type.orEmpty()
1779+ val pattern = permission.patternText.orEmpty()
1780+ if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
1781+ }
1748 enqueuePermission(permission)
1782 enqueuePermission(permission)
1749 }
1783 }
1750 }
1784 }
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+1−1
@@ -157,7 +157,7 @@ private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithPa
157 sb.append(" · $assistantCount assistant message")
157 sb.append(" · $assistantCount assistant message")
158 if (assistantCount != 1) sb.append("s")
158 if (assistantCount != 1) sb.append("s")
159 if (firstTime != null && lastTime != null) {
159 if (firstTime != null && lastTime != null) {
160− sb.append(" · $firstTime → $lastTime")
160+ sb.append(" · ${formatExportTimestamp(firstTime)} → ${formatExportTimestamp(lastTime)}")
161 }
161 }
162 sb.append("\n\n---\n\n")
162 sb.append("\n\n---\n\n")
163}
163}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+29−11
@@ -65,6 +65,7 @@ import soy.iko.opencode.R
65import soy.iko.opencode.data.model.AssistantMessage
65import soy.iko.opencode.data.model.AssistantMessage
66import soy.iko.opencode.data.model.MessageWithParts
66import soy.iko.opencode.data.model.MessageWithParts
67import soy.iko.opencode.data.model.ModelOption
67import soy.iko.opencode.data.model.ModelOption
68+import soy.iko.opencode.data.model.Part
68import soy.iko.opencode.data.model.FilePart
69import soy.iko.opencode.data.model.FilePart
69import soy.iko.opencode.data.model.TextPart
70import soy.iko.opencode.data.model.TextPart
70import soy.iko.opencode.data.model.UnknownMessage
71import soy.iko.opencode.data.model.UnknownMessage
@@ -527,8 +528,20 @@ private fun UnknownMessageBlock(
527 PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
528 PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
528 }
529 }
529 }
530 }
530− MessageTimestampText(message.info)
531+ MessageTimestampText(message.info)
531− }
532+ }
533+ }
534+
535+/** Build the a11y contentDescription for the user bubble: the role label prefixed to the
536+ * message's text parts (or just the role when there's no text). Setting contentDescription on
537+ * a mergeDescendants node overrides child text, so the text must be included here — otherwise
538+ * TalkBack would read only "You" and lose the message body. */
539+private fun userBubbleA11yLabel(parts: List<Part>, roleLabel: String): String {
540+ val text = parts
541+ .filterIsInstance<TextPart>()
542+ .joinToString("\n\n") { it.text }
543+ .takeIf { it.isNotBlank() }
544+ return if (text != null) "$roleLabel, $text" else roleLabel
532}
545}
533
546
534@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
547@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@@ -556,9 +569,12 @@ private fun UserBubble(
556 val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
569 val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
557 val hasAttachments = message.parts.any { it is FilePart }
570 val hasAttachments = message.parts.any { it is FilePart }
558 // A11y: announce the speaker role so a screen-reader user can tell user from
571 // A11y: announce the speaker role so a screen-reader user can tell user from
559− // assistant bubbles without inferring from bubble position. Prefixed onto the
572+ // assistant bubbles without inferring from bubble position. Setting contentDescription
560− // bubble's merged semantics so TalkBack reads "You, <message text>".
573+ // on a mergeDescendants node overrides child text (TalkBack reads only contentDescription,
574+ // not the merged text), so include the message text IN the contentDescription — otherwise
575+ // TalkBack would read only "You" and lose the message body.
561 val roleLabel = stringResource(R.string.role_user_message)
576 val roleLabel = stringResource(R.string.role_user_message)
577+ val a11yLabel = remember(message.parts, roleLabel) { userBubbleA11yLabel(message.parts, roleLabel) }
562 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
578 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
563 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
579 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
564 // The menu anchors at the actual touch point rather than a fixed corner.
580 // The menu anchors at the actual touch point rather than a fixed corner.
@@ -589,8 +605,10 @@ private fun UserBubble(
589 .background(MaterialTheme.colorScheme.primaryContainer)
605 .background(MaterialTheme.colorScheme.primaryContainer)
590 // Merge descendants and prefix the role so TalkBack reads "You, <text>"
606 // Merge descendants and prefix the role so TalkBack reads "You, <text>"
591 // instead of just the message text — letting a screen-reader user tell
607 // instead of just the message text — letting a screen-reader user tell
592− // user from assistant bubbles without inferring from position.
608+ // user from assistant bubbles without inferring from position. The composite
609+ // contentDescription includes the text (see a11yLabel) since setting
610+ // contentDescription on a merge node overrides child text semantics.
593− .semantics(mergeDescendants = true) { contentDescription = roleLabel }
611+ .semantics(mergeDescendants = true) { contentDescription = a11yLabel }
594 // Long-press anywhere on the bubble (body or footer) opens the context menu —
612 // Long-press anywhere on the bubble (body or footer) opens the context menu —
595 // the conventional Android pattern — so the actions are discoverable without
613 // the conventional Android pattern — so the actions are discoverable without
596 // spotting the 18dp inline icons. detectTapGestures with only onLongPress does
614 // spotting the 18dp inline icons. detectTapGestures with only onLongPress does
@@ -842,10 +860,6 @@ private fun AssistantBlock(
842 // View-source dialog state: shown from the long-press menu's "View source" entry to display
860 // View-source dialog state: shown from the long-press menu's "View source" entry to display
843 // the raw markdown of the message's TextParts.
861 // the raw markdown of the message's TextParts.
844 var showSource by remember { mutableStateOf(false) }
862 var showSource by remember { mutableStateOf(false) }
845− // A11y: announce the speaker role so TalkBack reads "Assistant, <text>" instead of
846− // only the model/agent label — giving a screen-reader user an explicit role signal
847− // matching the user bubble's "You" prefix.
848− val assistantRoleLabel = stringResource(R.string.role_assistant_message)
849 // Transient highlight background (from global-search deep link). Animated so the highlight
863 // Transient highlight background (from global-search deep link). Animated so the highlight
850 // fades in/out rather than flashing, and so the background clears smoothly when the focus
864 // fades in/out rather than flashing, and so the background clears smoothly when the focus
851 // is cleared after the delay.
865 // is cleared after the delay.
@@ -865,7 +879,11 @@ private fun AssistantBlock(
865 Modifier
879 Modifier
866 },
880 },
867 )
881 )
882+ // Merge descendants so TalkBack reads the message parts' own text semantics, but
883+ // don't set contentDescription — that would override the child text and leave
884+ // TalkBack announcing only "Assistant" while the actual reply is never spoken.
885+ // The AssistantHeader above provides the role context as a heading.
868− .semantics(mergeDescendants = true) { contentDescription = assistantRoleLabel }
886+ .semantics(mergeDescendants = true) {}
869 .pointerInput(Unit) {
887 .pointerInput(Unit) {
870 detectTapGestures(onLongPress = { offset ->
888 detectTapGestures(onLongPress = { offset ->
871 assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
889 assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+9−4
@@ -87,6 +87,14 @@ import soy.iko.opencode.R
87
87
88private val COLLAPSED_LIMIT get() = NetworkConfig.toolOutputCollapsedLimitChars
88private val COLLAPSED_LIMIT get() = NetworkConfig.toolOutputCollapsedLimitChars
89
89
90+/** Build the collapsed preview of [detail]: the first [COLLAPSED_LIMIT] chars, trimmed back to
91+ * the last complete line when [isDiff] AND a real char truncation occurred. Trimming on a
92+ * diff that was collapsed by the line cap (not the char cap) would drop its final line. */
93+private fun collapseDiffPreview(detail: String, isDiff: Boolean): String {
94+ val head = detail.take(COLLAPSED_LIMIT)
95+ return if (isDiff && detail.length > COLLAPSED_LIMIT) head.substringBeforeLast('\n') else head
96+}
97+
90// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
98// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
91// resilient decoding) so this stays human-readable; constructed lazily and memoized.
99// resilient decoding) so this stays human-readable; constructed lazily and memoized.
92private val prettyJson: Json = Json { prettyPrint = true }
100private val prettyJson: Json = Json { prettyPrint = true }
@@ -456,10 +464,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
456 val exceedsLimit = detail.length > COLLAPSED_LIMIT ||
464 val exceedsLimit = detail.length > COLLAPSED_LIMIT ||
457 lineCount > NetworkConfig.toolOutputCollapsedLimitLines
465 lineCount > NetworkConfig.toolOutputCollapsedLimitLines
458 val collapsed = remember(detail, isDiff) {
466 val collapsed = remember(detail, isDiff) {
459− val head = detail.take(COLLAPSED_LIMIT)
467+ collapseDiffPreview(detail, isDiff)
460− // Truncating a diff mid-line makes DiffView render a malformed final line, so
461− // trim back to the last complete line when the content is a diff.
462− if (isDiff) head.substringBeforeLast('\n') else head
463 }
468 }
464 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
469 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
465 val expandedState = stringResource(R.string.state_expanded)
470 val expandedState = stringResource(R.string.state_expanded)
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+21−20
@@ -188,11 +188,6 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
188 // `@@` range text (rare but possible) are still distinct toggles. The KDoc promised this
188 // `@@` range text (rare but possible) are still distinct toggles. The KDoc promised this
189 // affordance; it's now implemented.
189 // affordance; it's now implemented.
190 val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
190 val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
191− // Resolve a syntax for highlighting from the first file header (if any). Memoized so a
192− // streaming re-parse doesn't re-resolve per token.
193− val syntax = remember(lines) {
194− lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
195− }
196 val palette = rememberHighlightPalette()
191 val palette = rememberHighlightPalette()
197
192
198 Column(
193 Column(
@@ -229,7 +224,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
229 )
224 )
230 }
225 }
231 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
226 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
232− rows.forEach { row -> RenderDiffRow(row, syntax, palette, collapsedHunks) }
227+ rows.forEach { row -> RenderDiffRow(row, palette, collapsedHunks) }
233 }
228 }
234 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
229 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
235 }
230 }
@@ -255,9 +250,6 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
255 val hScrollState = rememberScrollState()
250 val hScrollState = rememberScrollState()
256 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
251 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
257 val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
252 val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
258− val syntax = remember(lines) {
259− lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
260− }
261 val palette = rememberHighlightPalette()
253 val palette = rememberHighlightPalette()
262 val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
254 val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
263
255
@@ -298,7 +290,7 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
298 rows,
290 rows,
299 key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
291 key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
300 ) { _, row ->
292 ) { _, row ->
301− RenderDiffRow(row, syntax, palette, collapsedHunks)
293+ RenderDiffRow(row, palette, collapsedHunks)
302 }
294 }
303 }
295 }
304 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
296 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
@@ -406,6 +398,10 @@ private data class DiffRowItem(
406 val oldLine: Int?,
398 val oldLine: Int?,
407 val newLine: Int?,
399 val newLine: Int?,
408 val hunkIndex: Int?,
400 val hunkIndex: Int?,
401+ // The syntax resolved from the file header this row belongs to. A multi-file diff switches
402+ // language at each FileHeader; carrying syntax per row (instead of one for the whole diff)
403+ // ensures a later file's hunk is highlighted with its own language, not the first file's.
404+ val syntax: FileSyntax?,
409)
405)
410
406
411/** Walk [visibleLines] advancing the old/new line counters per hunk, producing a list of
407/** Walk [visibleLines] advancing the old/new line counters per hunk, producing a list of
@@ -426,6 +422,9 @@ private fun buildDiffRows(
426 var oldLine = 0
422 var oldLine = 0
427 var newLine = 0
423 var newLine = 0
428 var hunkIndex = -1
424 var hunkIndex = -1
425+ // Track the current file's syntax as we walk the list, re-resolving at each FileHeader so
426+ // a multi-file diff highlights each hunk with its own language instead of the first file's.
427+ var currentSyntax: FileSyntax? = null
429 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
428 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
430 return visibleLines.map { line ->
429 return visibleLines.map { line ->
431 when (line) {
430 when (line) {
@@ -436,21 +435,24 @@ private fun buildDiffRows(
436 oldLine = m.groupValues[1].toInt()
435 oldLine = m.groupValues[1].toInt()
437 newLine = m.groupValues[2].toInt()
436 newLine = m.groupValues[2].toInt()
438 }
437 }
439− DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null, hunkIndex)
438+ DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null, hunkIndex, currentSyntax)
439+ }
440+ is DiffLine.FileHeader -> {
441+ currentSyntax = syntaxForHeader(line.text)
442+ DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null, null, currentSyntax)
440 }
443 }
441− is DiffLine.FileHeader -> DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null, null)
442− is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null, null)
444+ is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null, null, currentSyntax)
443 is DiffLine.Add -> {
445 is DiffLine.Add -> {
444 val n = newLine++
446 val n = newLine++
445− DiffRowItem(line, line.text, addColor, addText, null, n, hunkIndex)
447+ DiffRowItem(line, line.text, addColor, addText, null, n, hunkIndex, currentSyntax)
446 }
448 }
447 is DiffLine.Remove -> {
449 is DiffLine.Remove -> {
448 val o = oldLine++
450 val o = oldLine++
449− DiffRowItem(line, line.text, removeColor, removeText, o, null, hunkIndex)
451+ DiffRowItem(line, line.text, removeColor, removeText, o, null, hunkIndex, currentSyntax)
450 }
452 }
451 is DiffLine.Context -> {
453 is DiffLine.Context -> {
452 val o = oldLine++; val n = newLine++
454 val o = oldLine++; val n = newLine++
453− DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n, hunkIndex)
455+ DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n, hunkIndex, currentSyntax)
454 }
456 }
455 }
457 }
456 }
458 }
@@ -462,7 +464,6 @@ private fun buildDiffRows(
462@Composable
464@Composable
463private fun RenderDiffRow(
465private fun RenderDiffRow(
464 row: DiffRowItem,
466 row: DiffRowItem,
465− syntax: FileSyntax?,
466 palette: HighlightPalette,
467 palette: HighlightPalette,
467 collapsedHunks: androidx.compose.runtime.MutableState<Set<Int>>,
468 collapsedHunks: androidx.compose.runtime.MutableState<Set<Int>>,
468) {
469) {
@@ -528,9 +529,9 @@ private fun RenderDiffRow(
528 color = MaterialTheme.colorScheme.onSurfaceVariant,
529 color = MaterialTheme.colorScheme.onSurfaceVariant,
529 modifier = Modifier.padding(vertical = 1.dp),
530 modifier = Modifier.padding(vertical = 1.dp),
530 )
531 )
531− is DiffLine.Add -> DiffRow(row.text, "+", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)
532+ is DiffLine.Add -> DiffRow(row.text, "+", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = row.syntax, palette = palette)
532− is DiffLine.Remove -> DiffRow(row.text, "-", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)
533+ is DiffLine.Remove -> DiffRow(row.text, "-", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = row.syntax, palette = palette)
533− is DiffLine.Context -> DiffRow(row.text, " ", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)
534+ is DiffLine.Context -> DiffRow(row.text, " ", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = row.syntax, palette = palette)
534 }
535 }
535}
536}
536
537
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+17−11
@@ -89,19 +89,25 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
89 // Keep already-loaded servers on screen during a reload; only show the full-screen
89 // Keep already-loaded servers on screen during a reload; only show the full-screen
90 // spinner on the very first load.
90 // spinner on the very first load.
91 if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
91 if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
92− runCatchingCancellable {
92+ try {
93+ runCatchingCancellable {
93− val config = conn.api.config()
94+ val config = conn.api.config()
94− // Fetch live status separately so a missing/unstable /mcp endpoint (older server
95+ // Fetch live status separately so a missing/unstable /mcp endpoint (older server
95− // builds, transient error) never blocks the config-driven view — it just omits the
96+ // builds, transient error) never blocks the config-driven view — it just omits the
96− // connected/error indicators for that load.
97+ // connected/error indicators for that load.
97− val status = runCatchingCancellable { conn.api.mcpStatus() }.getOrNull()
98+ val status = runCatchingCancellable { conn.api.mcpStatus() }.getOrNull()
98− withContext(Dispatchers.Default) {
99+ withContext(Dispatchers.Default) {
99− mergeMcpStatus(parseMcpServers(config), status)
100+ mergeMcpStatus(parseMcpServers(config), status)
101+ }
100 }
102 }
103+ .onSuccess { _state.value = State.Ready(it) }
104+ .onFailure { _state.value = State.Error(container.friendlyError(it)) }
105+ } finally {
106+ // Reset in finally so a collectLatest cancellation (which rethrows
107+ // CancellationException out of runCatchingCancellable, skipping the lines above)
108+ // can't leave the pull-to-refresh spinner latched true.
109+ _refreshing.value = false
101 }
110 }
102− .onSuccess { _state.value = State.Ready(it) }
103− .onFailure { _state.value = State.Error(container.friendlyError(it)) }
104− _refreshing.value = false
105 }
111 }
106
112
107 /** Register a new MCP server via `POST /mcp`. Builds the server config object from the
113 /** Register a new MCP server via `POST /mcp`. Builds the server config object from the
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+41−29
@@ -80,6 +80,12 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
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 val inputs = cachedInputs ?: return
82 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
83 val cutoff = _timeRange.value.cutoffMs()
89 val cutoff = _timeRange.value.cutoffMs()
84 withContext(Dispatchers.Default) {
90 withContext(Dispatchers.Default) {
85 val report = aggregateUsage(inputs, cutoff)
91 val report = aggregateUsage(inputs, cutoff)
@@ -95,38 +101,44 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
95 // Keep the current report visible during a manual reload; only blank to the full-screen
101 // Keep the current report visible during a manual reload; only blank to the full-screen
96 // spinner on the first load.
102 // spinner on the first load.
97 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
98− runCatchingCancellable {
104+ try {
105+ runCatchingCancellable {
99− val sessions = conn.repository.listSessions()
106+ val sessions = conn.repository.listSessions()
100− // Cap concurrent message fetches so a large history doesn't open dozens of
107+ // Cap concurrent message fetches so a large history doesn't open dozens of
101− // sockets at once; a per-session fetch failure yields an empty list rather
108+ // sockets at once; a per-session fetch failure yields an empty list rather
102− // than aborting the whole report.
109+ // than aborting the whole report.
103− val limiter = Semaphore(MAX_CONCURRENT_FETCHES)
110+ val limiter = Semaphore(MAX_CONCURRENT_FETCHES)
104− // coroutineScope so the per-session fetches are children of THIS load: when
111+ // coroutineScope so the per-session fetches are children of THIS load: when
105− // collectLatest cancels a stale load (connection changed / manual reload), the
112+ // collectLatest cancels a stale load (connection changed / manual reload), the
106− // in-flight fetches are cancelled with it instead of leaking.
113+ // in-flight fetches are cancelled with it instead of leaking.
107− val perSession = coroutineScope {
114+ val perSession = coroutineScope {
108− sessions.map { session ->
115+ sessions.map { session ->
109− async {
116+ async {
110− limiter.withPermit {
117+ limiter.withPermit {
111− val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
118+ val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
112− .getOrDefault(emptyList())
119+ .getOrDefault(emptyList())
113− UsageSessionInput(
120+ UsageSessionInput(
114− sessionId = session.id,
121+ sessionId = session.id,
115− title = session.displayTitle,
122+ title = session.displayTitle,
116− modifiedAt = session.time?.updated ?: session.time?.created,
123+ modifiedAt = session.time?.updated ?: session.time?.created,
117− messages = messages,
124+ messages = messages,
118− )
125+ )
126+ }
119 }
127 }
120− }
121− }.awaitAll()
128+ }.awaitAll()
129+ }
130+ cachedInputs = perSession
131+ val cutoff = _timeRange.value.cutoffMs()
132+ withContext(Dispatchers.Default) { aggregateUsage(perSession, cutoff) }
122 }
133 }
123− cachedInputs = perSession
134+ .onSuccess { _state.value = State.Ready(it) }
124− val cutoff = _timeRange.value.cutoffMs()
135+ .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
125− withContext(Dispatchers.Default) { aggregateUsage(perSession, cutoff) }
136+ } finally {
137+ // Reset in finally so a collectLatest cancellation (which rethrows
138+ // CancellationException out of runCatchingCancellable, skipping the lines above)
139+ // can't leave the pull-to-refresh spinner latched true.
140+ _refreshing.value = false
126 }
141 }
127− .onSuccess { _state.value = State.Ready(it) }
128− .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
129− _refreshing.value = false
130 }
142 }
131
143
132 private companion object {
144 private companion object {