git.iko.soy/opencode-android
on master help sign in
Exclude muted sessions from unread badges and fix continuation Stop-Undo
00849aaiko
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+8−2
@@ -322,8 +322,14 @@ open class AppContainer private constructor(
322 * session list's "Mark as unread" overflow action so a user can re-flag a conversation
322 * session list's "Mark as unread" overflow action so a user can re-flag a conversation
323 * they want to revisit. No-op when the session is the one currently being viewed — a
323 * they want to revisit. No-op when the session is the one currently being viewed — a
324 * viewed session clears its own unread badge via [setCurrentSession], so marking it
324 * viewed session clears its own unread badge via [setCurrentSession], so marking it
325− * unread would race and immediately clear. Muted sessions are still marked (the count is
325+ * unread would race and immediately clear.
326+ *
327+ * Unlike the SSE-driven unread tracking in [observeMessageActivity] (which skips muted
328+ * sessions so a muted conversation doesn't accumulate counts from incoming events), this
329+ * manual action does NOT skip muted sessions: the count is stored in [_unread] regardless
326− * tracked so unmuting restores it) but the list UI hides the badge while muted. */
330+ * so unmuting restores it, and the list UI hides the badge while muted. The launcher-icon
331+ * and nav badges (in SessionListScreen / OpencodeApp) filter out muted sessions when
332+ * summing the total, so a manually-marked-unread muted session doesn't bump those. */
327 open fun markSessionUnread(id: String, count: Int = 1) {
333 open fun markSessionUnread(id: String, count: Int = 1) {
328 if (id == _currentSession.value) return
334 if (id == _currentSession.value) return
329 _unread.update { it + (id to count) }
335 _unread.update { it + (id to count) }
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+16−6
@@ -44,11 +44,14 @@ object SessionNotifications {
44 private const val UNREAD_BADGE_ID = -10
44 private const val UNREAD_BADGE_ID = -10
45 private const val CONNECTION_LOST_ID = -11
45 private const val CONNECTION_LOST_ID = -11
46
46
47− // Distinct namespaces so a session can have a completion, a permission, and an error
47+ // Distinct namespaces so a session can have a completion, a permission, an error, and an
48− // notification outstanding at once without their ids colliding.
48+ // outbox-dropped notification outstanding at once without their ids colliding. The outbox
49+ // drop is semantically distinct from a run error (no run failed — a queued reply couldn't
50+ // be delivered), so it gets its own namespace rather than overwriting a prior postError.
49 private const val NS_COMPLETED = "done"
51 private const val NS_COMPLETED = "done"
50 private const val NS_PERMISSION = "perm"
52 private const val NS_PERMISSION = "perm"
51 private const val NS_ERROR = "err"
53 private const val NS_ERROR = "err"
54+ private const val NS_OUTBOX_DROPPED = "outbox"
52
55
53 private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
56 private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
54
57
@@ -395,11 +398,14 @@ object SessionNotifications {
395 /** A queued outbox message was permanently undeliverable (e.g. the target session was
398 /** A queued outbox message was permanently undeliverable (e.g. the target session was
396 * deleted server-side → 404). Distinct from [postError] (which is titled "Run failed"):
399 * deleted server-side → 404). Distinct from [postError] (which is titled "Run failed"):
397 * no run failed — a queued reply couldn't be delivered. Uses a distinct title/body so the
400 * no run failed — a queued reply couldn't be delivered. Uses a distinct title/body so the
398− * user understands the failure mode and recovery action (open the session and re-send). */
401+ * user understands the failure mode and recovery action (open the session and re-send).
402+ * Uses a distinct notification namespace ([NS_OUTBOX_DROPPED]) so it doesn't overwrite a
403+ * prior [postError] for the same session — the two failure modes are independent and the
404+ * user may need to see both. */
399 @SuppressLint("MissingPermission")
405 @SuppressLint("MissingPermission")
400 fun postOutboxDropped(context: Context, sessionId: String, sessionTitle: String, profileId: String? = null) {
406 fun postOutboxDropped(context: Context, sessionId: String, sessionTitle: String, profileId: String? = null) {
401 if (!canPost(context)) return
407 if (!canPost(context)) return
402− val notifId = notifId(NS_ERROR, sessionId)
408+ val notifId = notifId(NS_OUTBOX_DROPPED, sessionId)
403 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
409 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
404 .setSmallIcon(R.drawable.ic_stat_notify)
410 .setSmallIcon(R.drawable.ic_stat_notify)
405 .setContentTitle(context.getString(R.string.message_not_delivered))
411 .setContentTitle(context.getString(R.string.message_not_delivered))
@@ -508,9 +514,13 @@ object SessionNotifications {
508
514
509 /** Cancel a session's error notification (e.g. when the user opens it). Without this a
515 /** Cancel a session's error notification (e.g. when the user opens it). Without this a
510 * stale "run failed" notification lingers when the session is opened by any route other
516 * stale "run failed" notification lingers when the session is opened by any route other
511− * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
517+ * than tapping the notification itself (setAutoCancel only clears it on a direct tap).
518+ * Also cancels an outbox-dropped notification ([postOutboxDropped]) since it shares the
519+ * error channel/group and should likewise clear on open. */
512 fun cancelError(context: Context, sessionId: String) {
520 fun cancelError(context: Context, sessionId: String) {
513− NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
521+ val nm = NotificationManagerCompat.from(context)
522+ nm.cancel(notifId(NS_ERROR, sessionId))
523+ nm.cancel(notifId(NS_OUTBOX_DROPPED, sessionId))
514 maybeCancelSummary(context, GROUP_ERROR, SUMMARY_ERROR_ID)
524 maybeCancelSummary(context, GROUP_ERROR, SUMMARY_ERROR_ID)
515 }
525 }
516
526
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+7−1
@@ -584,9 +584,15 @@ fun OpencodeApp(container: AppContainer) {
584
584
585 // Collect unread/run state for nav badges (rail on large screens, bottom bar on
585 // Collect unread/run state for nav badges (rail on large screens, bottom bar on
586 // compact screens), so a user on Files/Settings sees pending activity at a glance.
586 // compact screens), so a user on Files/Settings sees pending activity at a glance.
587+ // Muted sessions are excluded from the nav badge so muting suppresses the badge here
588+ // too, not just the in-list row badge — mirrors the launcher-icon badge in
589+ // SessionListScreen and the SSE-driven unread tracking in AppContainer.
587 val unread by container.unread.collectAsStateWithLifecycle()
590 val unread by container.unread.collectAsStateWithLifecycle()
591+ val mutedSessions by container.mutedSessions.collectAsStateWithLifecycle()
588 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
592 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
589− val totalUnread = remember(unread) { unread.values.sum() }
593+ val totalUnread = remember(unread, mutedSessions) {
594+ unread.filterKeys { it !in mutedSessions }.values.sum()
595+ }
590
596
591 if (showNavRail) {
597 if (showNavRail) {
592 Row(modifier = Modifier.fillMaxSize()) {
598 Row(modifier = Modifier.fillMaxSize()) {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+28−26
@@ -183,7 +183,7 @@ import soy.iko.opencode.ui.components.DiffView
183import soy.iko.opencode.ui.components.PaletteAction
183import soy.iko.opencode.ui.components.PaletteAction
184import soy.iko.opencode.ui.components.LocalReducedMotion
184import soy.iko.opencode.ui.components.LocalReducedMotion
185import soy.iko.opencode.ui.components.LocalSearchHighlight
185import soy.iko.opencode.ui.components.LocalSearchHighlight
186−import soy.iko.opencode.ui.components.reducedMotionAnimateItem
186+
187import soy.iko.opencode.ui.components.copyToClipboard
187import soy.iko.opencode.ui.components.copyToClipboard
188import soy.iko.opencode.ui.components.LocalRelativeTimeTick
188import soy.iko.opencode.ui.components.LocalRelativeTimeTick
189import soy.iko.opencode.ui.components.rememberRelativeTimeTick
189import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -1483,30 +1483,28 @@ fun ChatScreen(
1483 val isFocusedMatch = focusedMatchId != null &&
1483 val isFocusedMatch = focusedMatchId != null &&
1484 item is MessageListItem.Message &&
1484 item is MessageListItem.Message &&
1485 item.message.info.id == focusedMatchId
1485 item.message.info.id == focusedMatchId
1486− // Default placement animation so inserted/moved rows glide in. Skipped
1486+ // No animateItem() here. It is backed by SizeAnimationModifierNode, which
1487− // entirely under reduced motion so a streaming message growing in place
1487+ // forces a draw-phase remeasure (the display-list rebuild triggered by
1488− // doesn't animate on every token (and respects the a11y preference).
1488+ // AndroidComposeView.dispatchDraw) when its target's size changes. That
1489+ // remeasure runs inside the draw pass and reads Placeable.getWidth()/
1490+ // getHeight() of a child whose measured size hasn't been committed yet —
1491+ // an NPE in PaddingValuesModifier.measure / FillNode.measure (the R8 map
1492+ // marks both sites with a throws(NullPointerException) rewriteFrame).
1489 //
1493 //
1490− // Also skipped for the actively-streaming message even at full motion.
1491− // animateItem() is backed by SizeAnimationModifierNode, which forces a
1492− // draw-phase remeasure (the display-list rebuild triggered by
1493− // AndroidComposeView.dispatchDraw) when its target's size changes. While
1494− // the streaming bubble grows token-by-token, that remeasure runs inside
1495− // the draw pass and reads Placeable.getWidth()/getHeight() of a child
1496− // whose measured size hasn't been committed yet — an NPE in
1497− // PaddingValuesModifier.measure / FillNode.measure (the R8 map marks both
1498− // sites with a throws(NullPointerException) rewriteFrame). This is the
1499− // same class of draw-phase-remeasure crash previously fixed for the
1494+ // This is the same crash class previously fixed for the markdown library's
1500− // markdown library's animateContentSize and MessageBubble's send-status
1495+ // animateContentSize and MessageBubble's send-status AnimatedContent. An
1501− // AnimatedContent; animateItem() on a continuously-resizing row is the
1496+ // earlier fix kept animateItem() but skipped it for the actively-streaming
1502− // remaining instance. Non-streaming rows (including the one that just
1497+ // row; that was insufficient because the crash also fires when OPENING a
1503− // finished streaming) keep the glide animation since their size is stable.
1498+ // chat (running = false, so every row kept animateItem()): historical rows
1504− val isStreamingMessage = item is MessageListItem.Message &&
1499+ // with async-loading content (DiffView's produceState starts empty and
1505− running &&
1500+ // fills a frame later; RemoteImage loading; large markdown) change size
1506− item.message.info.id == lastMessageId
1501+ // after mount and trigger the same draw-phase remeasure. animateItem() is
1502+ // fundamentally unsafe on any chat row whose size can change post-mount,
1503+ // so it is removed entirely. The glide-in animation it provided is
1504+ // decorative (the list already auto-scrolls to new messages) and is not
1505+ // worth a 4th round of this crash.
1507 Box(
1506 Box(
1508 Modifier
1507 Modifier
1509− .then(if (isStreamingMessage) Modifier else reducedMotionAnimateItem())
1510 .then(
1508 .then(
1511 if (isFocusedMatch) {
1509 if (isFocusedMatch) {
1512 Modifier.background(
1510 Modifier.background(
@@ -1799,11 +1797,15 @@ fun ChatScreen(
1799 }
1797 }
1800 Row(
1798 Row(
1801 verticalAlignment = Alignment.CenterVertically,
1799 verticalAlignment = Alignment.CenterVertically,
1802− // Animate this slot in/out so the working row doesn't pop in and
1800+ // No animateItem() here for the same reason as the message rows
1803− // jolt the message list when a run starts/stops — matching the
1801+ // above: the __typing row's size changes (the elapsed-time Text
1804− // AnimatedVisibility used by every other transient banner/FAB here.
1802+ // grows from "0:00" to "M:SS" and the spinner mounts on first
1803+ // frame), and animateItem()'s SizeAnimationModifierNode forces a
1804+ // draw-phase remeasure that NPEs on Placeable.getWidth(). The
1805+ // working row is mounted/unmounted as a whole item (key="__typing")
1806+ // by LazyColumn, which already crossfades the appearance; the
1807+ // placement animation is not needed.
1805 modifier = Modifier
1808 modifier = Modifier
1806− .then(reducedMotionAnimateItem())
1807 .padding(vertical = 4.dp),
1809 .padding(vertical = 4.dp),
1808 ) {
1810 ) {
1809 // Merge the indicator + label + clock into one TalkBack node so
1811 // Merge the indicator + label + clock into one TalkBack node so
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+4
@@ -1638,6 +1638,10 @@ class ChatViewModel(
1638 // every streamed part of the continuation, leaving the working indicator / Stop button /
1638 // every streamed part of the continuation, leaving the working indicator / Stop button /
1639 // foreground service off for the whole run.
1639 // foreground service off for the whole run.
1640 runEndedByIdle = false
1640 runEndedByIdle = false
1641+ // Track the synthetic "continue" prompt so an undoable Stop re-sends it (not the prior
1642+ // real prompt), matching send()'s lastSentPrompt contract. Without this a Stop-Undo mid-
1643+ // continuation would silently re-send the previous user prompt instead of "continue".
1644+ lastSentPrompt = "continue"
1641 viewModelScope.launch {
1645 viewModelScope.launch {
1642 // Route through the repository (not conn.api directly) so the prompt inherits
1646 // Route through the repository (not conn.api directly) so the prompt inherits
1643 // withRetry (transient-failure backoff) and the user's selected model/agent — a
1647 // withRetry (transient-failure backoff) and the user's selected model/agent — a
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+6−1
@@ -239,7 +239,12 @@ fun SessionListScreen(
239
239
240 // Reflect total unread sessions as a launcher-icon badge (silent notification on a
240 // Reflect total unread sessions as a launcher-icon badge (silent notification on a
241 // badge-enabled channel). Skips itself when the session list isn't loading a server yet.
241 // badge-enabled channel). Skips itself when the session list isn't loading a server yet.
242+ // Muted sessions are excluded so muting a conversation suppresses the launcher badge too,
243+ // not just the in-list row badge — a muted session marked unread manually would otherwise
244+ // still bump the launcher count despite being invisible in the list.
242− val totalUnread = remember(unread) { unread.values.sum() }
245+ val totalUnread = remember(unread, mutedSessions) {
246+ unread.filterKeys { it !in mutedSessions }.values.sum()
247+ }
243 LaunchedEffect(totalUnread) {
248 LaunchedEffect(totalUnread) {
244 soy.iko.opencode.notification.SessionNotifications.updateUnreadBadge(platformContext, totalUnread)
249 soy.iko.opencode.notification.SessionNotifications.updateUnreadBadge(platformContext, totalUnread)
245 }
250 }