git.iko.soy/opencode-android
on master help sign in
Fix 10 bugs across chat, usage, MCP, diff, and a11y layers
commitbe8d1026a7a7821163e429083b18916ddd5de261 authoriko <mail@iko.soy> dateSun 5 Jul 2026, 06:11 (3 months ago) parent4cdfbda
8 files changed+156−80
app/src/main/java/soy/iko/opencode/data/model/Todo.kt+1−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+37−3 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+1−1 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+29−11 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+9−4 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+21−20 app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+17−11 app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+41−29
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
3535 "high", "h", "1", "urgent", "critical" -> TodoPriority.HIGH
3636 "medium", "med", "m", "normal", "2" -> TodoPriority.MEDIUM
3737 "low", "l", "3", "minor", "backlog" -> TodoPriority.LOW
38− else -> if (priority.isNullOrBlank()) TodoPriority.NONE else TodoPriority.NONE
38+ else -> TodoPriority.NONE
3939}
4040
4141fun 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(
14791479 fun continueRun(@Suppress("UNUSED_PARAMETER") assistantMessageId: String) {
14801480 val conn = connection ?: return
14811481 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
14821489 viewModelScope.launch {
14831490 runCatchingCancellable {
14841491 conn.api.sendPrompt(sessionId, text = "continue")
1485− }.onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1492+ }.onFailure {
1493+ _running.value = false
1494+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1495+ }
14861496 }
14871497 }
14881498
@@ -1492,10 +1502,23 @@ class ChatViewModel(
14921502 * effectively replacing the edited message; the reverted banner's Undo restores it.
14931503 * Overwrites the current draft — this is an explicit edit action on that message. */
14941504 fun editMessage(messageId: String, text: String) {
1495− revertTo(messageId)
1505+ val conn = connection ?: return
14961506 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
14971507 _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+ }
14991522 }
15001523
15011524 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
@@ -1745,6 +1768,17 @@ class ChatViewModel(
17451768 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
17461769 .onFailure {
17471770 _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+ }
17481782 enqueuePermission(permission)
17491783 }
17501784 }
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
157157 sb.append(" · $assistantCount assistant message")
158158 if (assistantCount != 1) sb.append("s")
159159 if (firstTime != null && lastTime != null) {
160− sb.append(" · $firstTime → $lastTime")
160+ sb.append(" · ${formatExportTimestamp(firstTime)} → ${formatExportTimestamp(lastTime)}")
161161 }
162162 sb.append("\n\n---\n\n")
163163}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+29−11
@@ -65,6 +65,7 @@ import soy.iko.opencode.R
6565import soy.iko.opencode.data.model.AssistantMessage
6666import soy.iko.opencode.data.model.MessageWithParts
6767import soy.iko.opencode.data.model.ModelOption
68+import soy.iko.opencode.data.model.Part
6869import soy.iko.opencode.data.model.FilePart
6970import soy.iko.opencode.data.model.TextPart
7071import soy.iko.opencode.data.model.UnknownMessage
@@ -527,8 +528,20 @@ private fun UnknownMessageBlock(
527528 PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
528529 }
529530 }
530− MessageTimestampText(message.info)
531− }
531+ MessageTimestampText(message.info)
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
532545}
533546
534547@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@@ -556,9 +569,12 @@ private fun UserBubble(
556569 val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
557570 val hasAttachments = message.parts.any { it is FilePart }
558571 // 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
560− // bubble's merged semantics so TalkBack reads "You, <message text>".
572+ // assistant bubbles without inferring from bubble position. Setting contentDescription
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.
561576 val roleLabel = stringResource(R.string.role_user_message)
577+ val a11yLabel = remember(message.parts, roleLabel) { userBubbleA11yLabel(message.parts, roleLabel) }
562578 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
563579 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
564580 // The menu anchors at the actual touch point rather than a fixed corner.
@@ -589,8 +605,10 @@ private fun UserBubble(
589605 .background(MaterialTheme.colorScheme.primaryContainer)
590606 // Merge descendants and prefix the role so TalkBack reads "You, <text>"
591607 // instead of just the message text — letting a screen-reader user tell
592− // user from assistant bubbles without inferring from position.
593− .semantics(mergeDescendants = true) { contentDescription = roleLabel }
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.
611+ .semantics(mergeDescendants = true) { contentDescription = a11yLabel }
594612 // Long-press anywhere on the bubble (body or footer) opens the context menu —
595613 // the conventional Android pattern — so the actions are discoverable without
596614 // spotting the 18dp inline icons. detectTapGestures with only onLongPress does
@@ -842,10 +860,6 @@ private fun AssistantBlock(
842860 // View-source dialog state: shown from the long-press menu's "View source" entry to display
843861 // the raw markdown of the message's TextParts.
844862 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)
849863 // Transient highlight background (from global-search deep link). Animated so the highlight
850864 // fades in/out rather than flashing, and so the background clears smoothly when the focus
851865 // is cleared after the delay.
@@ -865,7 +879,11 @@ private fun AssistantBlock(
865879 Modifier
866880 },
867881 )
868− .semantics(mergeDescendants = true) { contentDescription = assistantRoleLabel }
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.
886+ .semantics(mergeDescendants = true) {}
869887 .pointerInput(Unit) {
870888 detectTapGestures(onLongPress = { offset ->
871889 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
8787
8888private val COLLAPSED_LIMIT get() = NetworkConfig.toolOutputCollapsedLimitChars
8989
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+
9098// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
9199// resilient decoding) so this stays human-readable; constructed lazily and memoized.
92100private val prettyJson: Json = Json { prettyPrint = true }
@@ -456,10 +464,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
456464 val exceedsLimit = detail.length > COLLAPSED_LIMIT ||
457465 lineCount > NetworkConfig.toolOutputCollapsedLimitLines
458466 val collapsed = remember(detail, isDiff) {
459− val head = detail.take(COLLAPSED_LIMIT)
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
467+ collapseDiffPreview(detail, isDiff)
463468 }
464469 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
465470 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
188188 // `@@` range text (rare but possible) are still distinct toggles. The KDoc promised this
189189 // affordance; it's now implemented.
190190 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− }
196191 val palette = rememberHighlightPalette()
197192
198193 Column(
@@ -229,7 +224,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
229224 )
230225 }
231226 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
232− rows.forEach { row -> RenderDiffRow(row, syntax, palette, collapsedHunks) }
227+ rows.forEach { row -> RenderDiffRow(row, palette, collapsedHunks) }
233228 }
234229 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
235230 }
@@ -255,9 +250,6 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
255250 val hScrollState = rememberScrollState()
256251 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
257252 val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
258− val syntax = remember(lines) {
259− lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
260− }
261253 val palette = rememberHighlightPalette()
262254 val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
263255
@@ -298,7 +290,7 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
298290 rows,
299291 key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
300292 ) { _, row ->
301− RenderDiffRow(row, syntax, palette, collapsedHunks)
293+ RenderDiffRow(row, palette, collapsedHunks)
302294 }
303295 }
304296 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
@@ -406,6 +398,10 @@ private data class DiffRowItem(
406398 val oldLine: Int?,
407399 val newLine: Int?,
408400 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?,
409405)
410406
411407/** Walk [visibleLines] advancing the old/new line counters per hunk, producing a list of
@@ -426,6 +422,9 @@ private fun buildDiffRows(
426422 var oldLine = 0
427423 var newLine = 0
428424 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
429428 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
430429 return visibleLines.map { line ->
431430 when (line) {
@@ -436,21 +435,24 @@ private fun buildDiffRows(
436435 oldLine = m.groupValues[1].toInt()
437436 newLine = m.groupValues[2].toInt()
438437 }
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)
440443 }
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)
443445 is DiffLine.Add -> {
444446 val n = newLine++
445− DiffRowItem(line, line.text, addColor, addText, null, n, hunkIndex)
447+ DiffRowItem(line, line.text, addColor, addText, null, n, hunkIndex, currentSyntax)
446448 }
447449 is DiffLine.Remove -> {
448450 val o = oldLine++
449− DiffRowItem(line, line.text, removeColor, removeText, o, null, hunkIndex)
451+ DiffRowItem(line, line.text, removeColor, removeText, o, null, hunkIndex, currentSyntax)
450452 }
451453 is DiffLine.Context -> {
452454 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)
454456 }
455457 }
456458 }
@@ -462,7 +464,6 @@ private fun buildDiffRows(
462464@Composable
463465private fun RenderDiffRow(
464466 row: DiffRowItem,
465− syntax: FileSyntax?,
466467 palette: HighlightPalette,
467468 collapsedHunks: androidx.compose.runtime.MutableState<Set<Int>>,
468469) {
@@ -528,9 +529,9 @@ private fun RenderDiffRow(
528529 color = MaterialTheme.colorScheme.onSurfaceVariant,
529530 modifier = Modifier.padding(vertical = 1.dp),
530531 )
531− is DiffLine.Add -> DiffRow(row.text, "+", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = 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.Context -> 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)
533+ is DiffLine.Remove -> DiffRow(row.text, "-", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = row.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)
534535 }
535536}
536537
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+17−11
@@ -89,19 +89,25 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
8989 // Keep already-loaded servers on screen during a reload; only show the full-screen
9090 // spinner on the very first load.
9191 if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
92− runCatchingCancellable {
93− val config = conn.api.config()
94− // 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− // connected/error indicators for that load.
97− val status = runCatchingCancellable { conn.api.mcpStatus() }.getOrNull()
98− withContext(Dispatchers.Default) {
99− mergeMcpStatus(parseMcpServers(config), status)
92+ try {
93+ runCatchingCancellable {
94+ val config = conn.api.config()
95+ // Fetch live status separately so a missing/unstable /mcp endpoint (older server
96+ // builds, transient error) never blocks the config-driven view — it just omits the
97+ // connected/error indicators for that load.
98+ val status = runCatchingCancellable { conn.api.mcpStatus() }.getOrNull()
99+ withContext(Dispatchers.Default) {
100+ mergeMcpStatus(parseMcpServers(config), status)
101+ }
100102 }
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
101110 }
102− .onSuccess { _state.value = State.Ready(it) }
103− .onFailure { _state.value = State.Error(container.friendlyError(it)) }
104− _refreshing.value = false
105111 }
106112
107113 /** 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() {
8080 /** Re-aggregate the cached fetch under the current time range, if a Ready report exists. */
8181 private suspend fun reaggregate() {
8282 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
8389 val cutoff = _timeRange.value.cutoffMs()
8490 withContext(Dispatchers.Default) {
8591 val report = aggregateUsage(inputs, cutoff)
@@ -95,38 +101,44 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
95101 // Keep the current report visible during a manual reload; only blank to the full-screen
96102 // spinner on the first load.
97103 if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
98− runCatchingCancellable {
99− val sessions = conn.repository.listSessions()
100− // 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
102− // than aborting the whole report.
103− val limiter = Semaphore(MAX_CONCURRENT_FETCHES)
104− // coroutineScope so the per-session fetches are children of THIS load: when
105− // collectLatest cancels a stale load (connection changed / manual reload), the
106− // in-flight fetches are cancelled with it instead of leaking.
107− val perSession = coroutineScope {
108− sessions.map { session ->
109− async {
110− limiter.withPermit {
111− val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
112− .getOrDefault(emptyList())
113− UsageSessionInput(
114− sessionId = session.id,
115− title = session.displayTitle,
116− modifiedAt = session.time?.updated ?: session.time?.created,
117− messages = messages,
118− )
104+ try {
105+ runCatchingCancellable {
106+ val sessions = conn.repository.listSessions()
107+ // Cap concurrent message fetches so a large history doesn't open dozens of
108+ // sockets at once; a per-session fetch failure yields an empty list rather
109+ // than aborting the whole report.
110+ val limiter = Semaphore(MAX_CONCURRENT_FETCHES)
111+ // coroutineScope so the per-session fetches are children of THIS load: when
112+ // collectLatest cancels a stale load (connection changed / manual reload), the
113+ // in-flight fetches are cancelled with it instead of leaking.
114+ val perSession = coroutineScope {
115+ sessions.map { session ->
116+ async {
117+ limiter.withPermit {
118+ val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
119+ .getOrDefault(emptyList())
120+ UsageSessionInput(
121+ sessionId = session.id,
122+ title = session.displayTitle,
123+ modifiedAt = session.time?.updated ?: session.time?.created,
124+ messages = messages,
125+ )
126+ }
119127 }
120− }
121− }.awaitAll()
128+ }.awaitAll()
129+ }
130+ cachedInputs = perSession
131+ val cutoff = _timeRange.value.cutoffMs()
132+ withContext(Dispatchers.Default) { aggregateUsage(perSession, cutoff) }
122133 }
123− cachedInputs = perSession
124− val cutoff = _timeRange.value.cutoffMs()
125− withContext(Dispatchers.Default) { aggregateUsage(perSession, cutoff) }
134+ .onSuccess { _state.value = State.Ready(it) }
135+ .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
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
126141 }
127− .onSuccess { _state.value = State.Ready(it) }
128− .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
129− _refreshing.value = false
130142 }
131143
132144 private companion object {