git.iko.soy/opencode-android
on master help sign in
Fix 8 bugs: PermissionDialog stale responded lockout, SSE collector terminal-error freeze, notif permission wrong-server routing, notif title wrong-connection resolution, share text-only re-share drop, TTS partial-enqueue Stop-button stuck, widget row id hash collision
49952c3iko
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+47−19
@@ -27,6 +27,7 @@ import soy.iko.opencode.data.repo.MessageCacheStore
27import soy.iko.opencode.data.repo.OutboxMessage
27import soy.iko.opencode.data.repo.OutboxMessage
28import soy.iko.opencode.data.repo.OutboxStore
28import soy.iko.opencode.data.repo.OutboxStore
29import soy.iko.opencode.data.repo.SessionPrefsStore
29import soy.iko.opencode.data.repo.SessionPrefsStore
30+import soy.iko.opencode.data.repo.SessionRepository
30import soy.iko.opencode.data.repo.ProfileStore
31import soy.iko.opencode.data.repo.ProfileStore
31import soy.iko.opencode.data.repo.SettingsStore
32import soy.iko.opencode.data.repo.SettingsStore
32import soy.iko.opencode.data.repo.classifyError
33import soy.iko.opencode.data.repo.classifyError
@@ -58,6 +59,7 @@ import kotlinx.coroutines.flow.emptyFlow
58import kotlinx.coroutines.flow.first
59import kotlinx.coroutines.flow.first
59import kotlinx.coroutines.Job
60import kotlinx.coroutines.Job
60import kotlinx.coroutines.flow.flatMapLatest
61import kotlinx.coroutines.flow.flatMapLatest
62+import kotlinx.coroutines.flow.map
61import kotlinx.coroutines.flow.update
63import kotlinx.coroutines.flow.update
62import kotlinx.coroutines.isActive
64import kotlinx.coroutines.isActive
63import kotlinx.coroutines.launch
65import kotlinx.coroutines.launch
@@ -450,14 +452,23 @@ open class AppContainer private constructor(
450 while (isActive) {
452 while (isActive) {
451 runCatchingCancellable {
453 runCatchingCancellable {
452 activeConnection
454 activeConnection
455+ .flatMapLatest { conn ->
456+ // Capture the originating connection so notification coroutines
457+ // resolve titles and route permission responses against THIS
458+ // server, not whichever happens to be active when they resume.
459+ // A server switch during a listSessions() round-trip would
460+ // otherwise resolve the title (or POST the permission reply)
461+ // against the new server, where the session/permission doesn't
462+ // exist — showing an opaque id or silently losing the reply.
453− .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
463+ conn?.events?.events?.map { conn to it } ?: emptyFlow()
464+ }
454− .collect { event ->
465+ .collect { (conn, event) ->
455 // A permission request needs the user's approval. Post a heads-up
466 // A permission request needs the user's approval. Post a heads-up
456 // notification (with Allow/Reject actions) unless the user is actively
467 // notification (with Allow/Reject actions) unless the user is actively
457 // viewing this session, where the in-app permission dialog handles it.
468 // viewing this session, where the in-app permission dialog handles it.
458 if (event is PermissionUpdated) {
469 if (event is PermissionUpdated) {
459 val psid = event.properties.sessionID.takeIf { it.isNotBlank() } ?: return@collect
470 val psid = event.properties.sessionID.takeIf { it.isNotBlank() } ?: return@collect
460− if (!isActivelyViewing(psid)) appScope.launch { notifyPermission(event.properties) }
471+ if (!isActivelyViewing(psid)) appScope.launch { notifyPermission(event.properties, conn) }
461 return@collect
472 return@collect
462 }
473 }
463 // The permission was answered (in-app, from the notification, or
474 // The permission was answered (in-app, from the notification, or
@@ -477,7 +488,7 @@ open class AppContainer private constructor(
477 }
488 }
478 }
489 }
479 if (wasRunning && !isActivelyViewing(esid)) {
490 if (wasRunning && !isActivelyViewing(esid)) {
480− appScope.launch { notifySessionError(esid) }
491+ appScope.launch { notifySessionError(esid, conn.repository) }
481 }
492 }
482 return@collect
493 return@collect
483 }
494 }
@@ -502,7 +513,7 @@ open class AppContainer private constructor(
502 // the shared-event collector here would stall the SharedFlow,
513 // the shared-event collector here would stall the SharedFlow,
503 // overflowing its DROP_OLDEST buffer and silently dropping live
514 // overflowing its DROP_OLDEST buffer and silently dropping live
504 // parts/updates for every other subscriber (e.g. the message reducer).
515 // parts/updates for every other subscriber (e.g. the message reducer).
505− appScope.launch { notifySessionCompleted(idleSid) }
516+ appScope.launch { notifySessionCompleted(idleSid, conn.repository) }
506 }
517 }
507 return@collect
518 return@collect
508 }
519 }
@@ -597,15 +608,18 @@ open class AppContainer private constructor(
597 const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
608 const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
598 }
609 }
599
610
600− /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id. */
611+ /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id.
612+ * Uses [originRepo] — the repository of the connection that emitted the event — so a
613+ * server switch during the listSessions() round-trip doesn't resolve the title against
614+ * an unrelated server (showing an opaque id, or worse, a colliding session's title). */
601− private suspend fun resolveSessionTitle(sessionId: String): String =
615+ private suspend fun resolveSessionTitle(sessionId: String, originRepo: SessionRepository): String =
602 runCatchingCancellable {
616 runCatchingCancellable {
603− activeConnection.value?.repository?.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
617+ originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
604 }.getOrNull() ?: sessionId
618 }.getOrNull() ?: sessionId
605
619
606 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
620 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
607− private suspend fun notifySessionCompleted(sessionId: String) {
621+ private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository) {
608− val title = resolveSessionTitle(sessionId)
622+ val title = resolveSessionTitle(sessionId, originRepo)
609 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
623 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
610 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
624 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
611 // If the user opened the session in that gap, setCurrentSession already cancelled the
625 // If the user opened the session in that gap, setCurrentSession already cancelled the
@@ -615,18 +629,20 @@ open class AppContainer private constructor(
615 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
629 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
616 }
630 }
617
631
618− /** Post a heads-up permission notification for [permission], resolving its session title. */
632+ /** Post a heads-up permission notification for [permission], resolving its session title.
633+ * [originConn] is the connection that emitted the permission event — its profile id is
634+ * embedded in the notification so the receiver routes the response back to THIS server. */
619− private suspend fun notifyPermission(permission: Permission) {
635+ private suspend fun notifyPermission(permission: Permission, originConn: OpencodeConnection) {
620 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
636 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
621− val title = resolveSessionTitle(sessionId)
637+ val title = resolveSessionTitle(sessionId, originConn.repository)
622 // Re-check viewing just before posting — see notifySessionCompleted for the race.
638 // Re-check viewing just before posting — see notifySessionCompleted for the race.
623 if (isActivelyViewing(sessionId)) return
639 if (isActivelyViewing(sessionId)) return
624− appContext?.let { SessionNotifications.postPermission(it, permission, title) }
640+ appContext?.let { SessionNotifications.postPermission(it, permission, title, originConn.profile.id) }
625 }
641 }
626
642
627 /** Post an error notification for a failed background run. */
643 /** Post an error notification for a failed background run. */
628− private suspend fun notifySessionError(sessionId: String) {
644+ private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository) {
629− val title = resolveSessionTitle(sessionId)
645+ val title = resolveSessionTitle(sessionId, originRepo)
630 // Re-check viewing just before posting — see notifySessionCompleted for the race.
646 // Re-check viewing just before posting — see notifySessionCompleted for the race.
631 if (isActivelyViewing(sessionId)) return
647 if (isActivelyViewing(sessionId)) return
632 appContext?.let { SessionNotifications.postError(it, sessionId, title) }
648 appContext?.let { SessionNotifications.postError(it, sessionId, title) }
@@ -638,11 +654,19 @@ open class AppContainer private constructor(
638 * connection existed AND respondPermission succeeded — the receiver uses it to decide
654 * connection existed AND respondPermission succeeded — the receiver uses it to decide
639 * whether to dismiss the notification. A missing connection makes the respond a no-op,
655 * whether to dismiss the notification. A missing connection makes the respond a no-op,
640 * so it must report failure (not success) rather than let the notification vanish with
656 * so it must report failure (not success) rather than let the notification vanish with
641− * the tool left unanswered. */
657+ * the tool left unanswered.
658+ *
659+ * [profileId] is the id of the server that posted the permission request (embedded in
660+ * the notification's PendingIntent). If it doesn't match the currently active
661+ * connection's profile, the response is NOT sent: posting it to a different server
662+ * would 404 (treated as "already resolved" success by respondPermission), dismissing
663+ * the notification while the original server's tool stays paused forever. Reporting
664+ * failure instead leaves the notification up so the user can switch back and retry. */
642 open fun respondToPermissionFromNotification(
665 open fun respondToPermissionFromNotification(
643 sessionId: String,
666 sessionId: String,
644 permissionId: String,
667 permissionId: String,
645 response: PermissionResponse,
668 response: PermissionResponse,
669+ profileId: String?,
646 onDone: (Boolean) -> Unit,
670 onDone: (Boolean) -> Unit,
647 ) {
671 ) {
648 appScope.launch {
672 appScope.launch {
@@ -653,8 +677,12 @@ open class AppContainer private constructor(
653 var success = false
677 var success = false
654 try {
678 try {
655 success = runCatchingCancellable {
679 success = runCatchingCancellable {
656− val api = activeConnection.value?.api ?: return@runCatchingCancellable false
680+ val conn = activeConnection.value ?: return@runCatchingCancellable false
681+ // Route to the originating server only. A mismatch means the user
682+ // switched servers while the permission was pending; sending the reply
683+ // to the wrong server would be silently swallowed as a 404-success.
684+ if (profileId != null && conn.profile.id != profileId) return@runCatchingCancellable false
657− api.respondPermission(sessionId, permissionId, response)
685+ conn.api.respondPermission(sessionId, permissionId, response)
658 true
686 true
659 }.onFailure {
687 }.onFailure {
660 Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
688 Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+3−1
@@ -28,11 +28,12 @@ class NotificationActionReceiver : BroadcastReceiver() {
28 when (intent.action) {
28 when (intent.action) {
29 ACTION_PERMISSION -> {
29 ACTION_PERMISSION -> {
30 val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
30 val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
31+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
31 val response = PermissionResponse.entries.firstOrNull {
32 val response = PermissionResponse.entries.firstOrNull {
32 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
33 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
33 } ?: return
34 } ?: return
34 val pending = goAsync()
35 val pending = goAsync()
35− container.respondToPermissionFromNotification(sessionId, permissionId, response) { success ->
36+ container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
36 // Only dismiss the notification once the tool was actually answered. If
37 // Only dismiss the notification once the tool was actually answered. If
37 // there was no live connection (respond is a no-op) or the call failed,
38 // there was no live connection (respond is a no-op) or the call failed,
38 // leave it up so the user can retry — otherwise it vanishes with the
39 // leave it up so the user can retry — otherwise it vanishes with the
@@ -62,6 +63,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
62 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
63 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
63 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
64 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
64 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
65 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
66+ const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
65 const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
67 const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
66 const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
68 const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
67 }
69 }
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+5−2
@@ -136,9 +136,11 @@ object SessionNotifications {
136
136
137 /** Post a heads-up notification for a permission request with Allow once / Always /
137 /** Post a heads-up notification for a permission request with Allow once / Always /
138 * Reject action buttons. [sessionTitle] labels the notification; [permission]'s title/
138 * Reject action buttons. [sessionTitle] labels the notification; [permission]'s title/
139− * pattern/type supplies the detail line. */
139+ * pattern/type supplies the detail line. [profileId] is embedded in the action intents
140+ * so the receiver routes the response back to the server that posted the request, not
141+ * whichever server happens to be active when the user taps. */
140 @SuppressLint("MissingPermission")
142 @SuppressLint("MissingPermission")
141− fun postPermission(context: Context, permission: Permission, sessionTitle: String) {
143+ fun postPermission(context: Context, permission: Permission, sessionTitle: String, profileId: String?) {
142 if (!canPost(context)) return
144 if (!canPost(context)) return
143 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
145 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
144 val notifId = notifId(NS_PERMISSION, sessionId)
146 val notifId = notifId(NS_PERMISSION, sessionId)
@@ -169,6 +171,7 @@ object SessionNotifications {
169 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
171 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
170 putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
172 putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
171 putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
173 putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
174+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
172 }
175 }
173 val pending = PendingIntent.getBroadcast(
176 val pending = PendingIntent.getBroadcast(
174 context, notifId + response.ordinal + 1, intent,
177 context, notifId + response.ordinal + 1, intent,
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.kt+22−5
@@ -8,6 +8,7 @@ import soy.iko.opencode.MainActivity
8import soy.iko.opencode.R
8import soy.iko.opencode.R
9import soy.iko.opencode.data.repo.RecentSession
9import soy.iko.opencode.data.repo.RecentSession
10import soy.iko.opencode.data.repo.RecentSessionsStore
10import soy.iko.opencode.data.repo.RecentSessionsStore
11+import java.security.MessageDigest
11
12
12/** Serves the list rows for [SessionsWidgetProvider] from [RecentSessionsStore]. */
13/** Serves the list rows for [SessionsWidgetProvider] from [RecentSessionsStore]. */
13class SessionsWidgetService : RemoteViewsService() {
14class SessionsWidgetService : RemoteViewsService() {
@@ -42,10 +43,26 @@ private class SessionsWidgetFactory(private val context: Context) : RemoteViewsS
42
43
43 override fun getLoadingView(): RemoteViews? = null
44 override fun getLoadingView(): RemoteViews? = null
44 override fun getViewTypeCount(): Int = 1
45 override fun getViewTypeCount(): Int = 1
45− // FIX 17: derive a stable id from the session id (not the position) so prepending a new
46+ // Derive a stable id from the session id via a collision-resistant SHA-256 digest
46− // session doesn't mis-recycle RemoteViews and show stale row content. Falls back to the
47+ // (truncated to 63 bits) rather than String.hashCode(), whose 32-bit output can
48+ // collide for distinct session ids — with hasStableIds=true the RemoteViews framework
49+ // would then mis-recycle two sessions' row content. Mirrors SessionNotifications.notifId.
47− // position for an out-of-range index.
50+ // Falls back to the position for an out-of-range index.
48− override fun getItemId(position: Int): Long =
51+ override fun getItemId(position: Int): Long {
49− items.getOrNull(position)?.id?.hashCode()?.toLong() ?: position.toLong()
52+ val id = items.getOrNull(position)?.id ?: return position.toLong()
53+ val digest = runCatching { MessageDigest.getInstance("SHA-256").digest(id.toByteArray()) }
54+ .getOrNull() ?: return position.toLong()
55+ // Take the first 8 bytes, mask to 63 bits (always positive) to fit a Long id.
56+ // The shl/or/and infix precedence requires parenthesizing each shift individually
57+ // (see SessionNotifications.notifId for the rationale).
58+ return (((digest[0].toLong() and 0xFF) shl 56) or
59+ ((digest[1].toLong() and 0xFF) shl 48) or
60+ ((digest[2].toLong() and 0xFF) shl 40) or
61+ ((digest[3].toLong() and 0xFF) shl 32) or
62+ ((digest[4].toLong() and 0xFF) shl 24) or
63+ ((digest[5].toLong() and 0xFF) shl 16) or
64+ ((digest[6].toLong() and 0xFF) shl 8) or
65+ (digest[7].toLong() and 0xFF)).and(0x7FFFFFFFFFFFFFFF)
66+ }
50 override fun hasStableIds(): Boolean = true
67 override fun hasStableIds(): Boolean = true
51}
68}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+9−4
@@ -76,10 +76,15 @@ fun OpencodeApp(container: AppContainer) {
76 var handledShareText by remember { mutableStateOf<String?>(null) }
76 var handledShareText by remember { mutableStateOf<String?>(null) }
77 var handledSharedMedia by remember { mutableStateOf<List<String>>(emptyList()) }
77 var handledSharedMedia by remember { mutableStateOf<List<String>>(emptyList()) }
78 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
78 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
79+ // Reset the remembered handled state for each channel independently as it drains,
80+ // so re-sharing the same payload after consumption is still recognized as new. A
81+ // joint reset (only when both are empty) would drop a text-only re-share that
82+ // follows a text+media share of the same text: the text channel matches its
83+ // remembered value while the media channel is empty (not "new"), so neither clause
84+ // fires and the share is silently swallowed.
79− if (pendingShare == null && pendingSharedMedia.isEmpty()) {
85+ if (pendingShare == null) handledShareText = null
80− handledSharedMedia = emptyList()
86+ if (pendingSharedMedia.isEmpty()) handledSharedMedia = emptyList()
81− return@LaunchedEffect
87+ if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
82− }
83 if (connection == null) return@LaunchedEffect
88 if (connection == null) return@LaunchedEffect
84 val newShare = (pendingShare != null && pendingShare != handledShareText) ||
89 val newShare = (pendingShare != null && pendingShare != handledShareText) ||
85 (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
90 (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+84−54
@@ -55,6 +55,8 @@ import kotlinx.coroutines.flow.update
55import kotlinx.coroutines.Dispatchers
55import kotlinx.coroutines.Dispatchers
56import kotlinx.coroutines.Job
56import kotlinx.coroutines.Job
57import kotlinx.coroutines.delay
57import kotlinx.coroutines.delay
58+import kotlinx.coroutines.isActive
59+import kotlinx.coroutines.currentCoroutineContext
58import kotlinx.coroutines.launch
60import kotlinx.coroutines.launch
59
61
60/**
62/**
@@ -598,62 +600,77 @@ class ChatViewModel(
598 }
600 }
599 }
601 }
600 try {
602 try {
603+ // Loop the inner collect so a transient non-cancellation exception (a
604+ // malformed SSE event decoding error, an unexpected channel close)
605+ // doesn't terminate the event collector for this connection. Without
606+ // the loop, the catch returns the collectLatest lambda normally, so
607+ // collectLatest considers this connection's block complete and never
608+ // re-runs it — the chat would go permanently silent (no streaming,
609+ // no permission prompts, no title updates) until a server switch.
610+ while (currentCoroutineContext().isActive) {
611+ try {
601− conn.events.events.collect { event ->
612+ conn.events.events.collect { event ->
602− if (SessionRepository.isIdle(event, sessionId)) {
613+ if (SessionRepository.isIdle(event, sessionId)) {
603− _running.value = false
614+ _running.value = false
604− runEndedByIdle = true
615+ runEndedByIdle = true
605− // Auto-send a queued follow-up once the previous run finishes,
616+ // Auto-send a queued follow-up once the previous run finishes,
606− // so the user's drafted-while-running message isn't lost.
617+ // so the user's drafted-while-running message isn't lost.
607− val queued = _queuedFollowUp.value
618+ val queued = _queuedFollowUp.value
608− if (queued != null) {
619+ if (queued != null) {
609− setQueuedFollowUp(null)
620+ setQueuedFollowUp(null)
610− send(queued, includeAttachments = false)
621+ send(queued, includeAttachments = false)
611− }
622+ }
612− }
613− if (SessionRepository.isError(event, sessionId)) {
614− _running.value = false
615− runEndedByIdle = true
616− // The run errored, so the queued follow-up won't auto-send.
617− // Restore it to the input (if free) so the user's typed text
618− // isn't silently lost; otherwise just clear the queue.
619− val queued = _queuedFollowUp.value
620− if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
621− _draft.value = queued
622− }
623− setQueuedFollowUp(null)
624− _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
625− }
626− // Restore the run indicator if a live streaming event arrives while it's
627− // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
628− // agent is still working. Don't revive it once the user has hit Stop.
629− val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
630− !_aborting.value && !_running.value && !runEndedByIdle
631− if (relightRunIndicator) {
632− _running.value = true
633− }
634− when (event) {
635− is PermissionUpdated ->
636− if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
637− is PermissionReplied ->
638− if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
639− is SessionUpdated ->
640− if (event.properties.info.id == sessionId) {
641− val info = event.properties.info
642− _sessionTitle.value = info.displayTitle
643− _reverted.value = info.isReverted
644− _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
645 }
623 }
646− is SessionDeleted ->
624+ if (SessionRepository.isError(event, sessionId)) {
647− if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
625+ _running.value = false
648− _sessionDeleted.value = true
626+ runEndedByIdle = true
627+ // The run errored, so the queued follow-up won't auto-send.
628+ // Restore it to the input (if free) so the user's typed text
629+ // isn't silently lost; otherwise just clear the queue.
630+ val queued = _queuedFollowUp.value
631+ if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
632+ _draft.value = queued
633+ }
634+ setQueuedFollowUp(null)
635+ _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
649 }
636 }
637+ // Restore the run indicator if a live streaming event arrives while it's
638+ // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
639+ // agent is still working. Don't revive it once the user has hit Stop.
640+ val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
641+ !_aborting.value && !_running.value && !runEndedByIdle
642+ if (relightRunIndicator) {
643+ _running.value = true
644+ }
645+ when (event) {
646+ is PermissionUpdated ->
647+ if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
648+ is PermissionReplied ->
649+ if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
650+ is SessionUpdated ->
651+ if (event.properties.info.id == sessionId) {
652+ val info = event.properties.info
653+ _sessionTitle.value = info.displayTitle
654+ _reverted.value = info.isReverted
655+ _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
656+ }
657+ is SessionDeleted ->
658+ if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
659+ _sessionDeleted.value = true
660+ }
650− else -> {}
661+ else -> {}
662+ }
663+ }
664+ } catch (e: CancellationException) {
665+ throw e
666+ } catch (e: Exception) {
667+ Log.w("ChatViewModel", "SSE event collector error, restarting", e)
651 }
668 }
652 }
669 }
653 } catch (e: CancellationException) {
670 } catch (e: CancellationException) {
654 throw e
671 throw e
655 } catch (e: Exception) {
672 } catch (e: Exception) {
656− Log.w("ChatViewModel", "SSE event collector error", e)
673+ Log.w("ChatViewModel", "SSE event collector loop error", e)
657 }
674 }
658 }
675 }
659 }
676 }
@@ -704,16 +721,29 @@ class ChatViewModel(
704 container.activeConnection.collectLatest { conn ->
721 container.activeConnection.collectLatest { conn ->
705 if (conn == null) return@collectLatest
722 if (conn == null) return@collectLatest
706 try {
723 try {
724+ // Loop so a transient non-cancellation exception doesn't terminate the
725+ // state watcher for this connection. Without the loop, a single thrown
726+ // error returns the collectLatest lambda and the _running reset on
727+ // Disconnected/Failed never fires again for this connection — a dropped
728+ // stream leaves the working spinner (and the RunForegroundService) stuck.
729+ while (currentCoroutineContext().isActive) {
730+ try {
707− conn.events.state.collect { state ->
731+ conn.events.state.collect { state ->
708− if ((state == EventStreamClient.ConnectionState.Disconnected ||
732+ if ((state == EventStreamClient.ConnectionState.Disconnected ||
709− state == EventStreamClient.ConnectionState.Failed) && _running.value) {
733+ state == EventStreamClient.ConnectionState.Failed) && _running.value) {
710− _running.value = false
734+ _running.value = false
735+ }
736+ }
737+ } catch (e: CancellationException) {
738+ throw e
739+ } catch (e: Exception) {
740+ Log.w("ChatViewModel", "SSE state collector error, restarting", e)
711 }
741 }
712 }
742 }
713 } catch (e: CancellationException) {
743 } catch (e: CancellationException) {
714 throw e
744 throw e
715 } catch (e: Exception) {
745 } catch (e: Exception) {
716− Log.w("ChatViewModel", "SSE state collector error", e)
746+ Log.w("ChatViewModel", "SSE state collector loop error", e)
717 }
747 }
718 }
748 }
719 }
749 }
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+7−2
@@ -17,7 +17,6 @@ import androidx.compose.material3.TextButton
17import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
18import androidx.compose.runtime.mutableStateOf
18import androidx.compose.runtime.mutableStateOf
19import androidx.compose.runtime.remember
19import androidx.compose.runtime.remember
20−import androidx.compose.runtime.saveable.rememberSaveable
21import androidx.compose.runtime.getValue
20import androidx.compose.runtime.getValue
22import androidx.compose.runtime.setValue
21import androidx.compose.runtime.setValue
23import androidx.compose.ui.Modifier
22import androidx.compose.ui.Modifier
@@ -46,7 +45,13 @@ fun PermissionDialog(
46 // so a null -> permissionB transition can collapse to just permissionB, reusing this
45 // so a null -> permissionB transition can collapse to just permissionB, reusing this
47 // composable for a different permission. Without the key `responded` would stay true
46 // composable for a different permission. Without the key `responded` would stay true
48 // and every button (and back-press) would be a no-op — an undismissable modal.
47 // and every button (and back-press) would be a no-op — an undismissable modal.
48+ // Plain `remember` (not `rememberSaveable`): a failed respondPermission re-enqueues
49+ // the same permission id (ChatViewModel.respondPermission -> enqueuePermission), and
50+ // rememberSaveable would restore a stale `true` from the Bundle across a config
51+ // change in between, locking the re-queued modal with all buttons no-op. Plain
52+ // remember resets to false on every fresh composition, so a re-queued permission is
53+ // always answerable.
49− var responded by rememberSaveable(permission.id) { mutableStateOf(false) }
54+ var responded by remember(permission.id) { mutableStateOf(false) }
50 val respond: (PermissionResponse) -> Unit = { response ->
55 val respond: (PermissionResponse) -> Unit = { response ->
51 if (!responded) {
56 if (!responded) {
52 responded = true
57 responded = true
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+10−3
@@ -77,9 +77,16 @@ class TtsController(context: Context) : RememberObserver {
77 // distinct id so their per-utterance onDone doesn't clear the Stop state early.
77 // distinct id so their per-utterance onDone doesn't clear the Stop state early.
78 val utteranceId = if (index == chunks.lastIndex) id else "$id#$index"
78 val utteranceId = if (index == chunks.lastIndex) id else "$id#$index"
79 val result = tts.speak(chunk, queueMode, null, utteranceId)
79 val result = tts.speak(chunk, queueMode, null, utteranceId)
80− // If even the first chunk can't be enqueued, nothing plays and no callback fires;
80+ // If any chunk can't be enqueued, the final chunk's onDone (which would clear
81+ // the Stop state) never fires — leaving the Stop button stuck with partial or
81− // bail without marking this message as speaking so the button doesn't stick.
82+ // no audio. Bail without marking this message as speaking so the button doesn't
83+ // stick. A mid-queue failure also breaks the contiguous playback, so stopping
84+ // is the safe default rather than playing a truncated prefix silently.
82− if (index == 0 && result != TextToSpeech.SUCCESS) { _speakingId.value = null; return }
85+ if (result != TextToSpeech.SUCCESS) {
86+ if (index == 0) _speakingId.value = null
87+ else stop()
88+ return
89+ }
83 }
90 }
84 _speakingId.value = id
91 _speakingId.value = id
85 }
92 }