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
commit49952c3d70dea24a3541904809e523344831cbb9 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 19:02 (3 months ago) parentca30f73
8 files changed+187−90
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+47−19 app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+3−1 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+5−2 app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.kt+22−5 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+9−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+84−54 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+7−2 app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+10−3
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+47−19
@@ -27,6 +27,7 @@ import soy.iko.opencode.data.repo.MessageCacheStore
2727import soy.iko.opencode.data.repo.OutboxMessage
2828import soy.iko.opencode.data.repo.OutboxStore
2929import soy.iko.opencode.data.repo.SessionPrefsStore
30+import soy.iko.opencode.data.repo.SessionRepository
3031import soy.iko.opencode.data.repo.ProfileStore
3132import soy.iko.opencode.data.repo.SettingsStore
3233import soy.iko.opencode.data.repo.classifyError
@@ -58,6 +59,7 @@ import kotlinx.coroutines.flow.emptyFlow
5859import kotlinx.coroutines.flow.first
5960import kotlinx.coroutines.Job
6061import kotlinx.coroutines.flow.flatMapLatest
62+import kotlinx.coroutines.flow.map
6163import kotlinx.coroutines.flow.update
6264import kotlinx.coroutines.isActive
6365import kotlinx.coroutines.launch
@@ -450,14 +452,23 @@ open class AppContainer private constructor(
450452 while (isActive) {
451453 runCatchingCancellable {
452454 activeConnection
453− .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
454− .collect { event ->
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.
463+ conn?.events?.events?.map { conn to it } ?: emptyFlow()
464+ }
465+ .collect { (conn, event) ->
455466 // A permission request needs the user's approval. Post a heads-up
456467 // notification (with Allow/Reject actions) unless the user is actively
457468 // viewing this session, where the in-app permission dialog handles it.
458469 if (event is PermissionUpdated) {
459470 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) }
461472 return@collect
462473 }
463474 // The permission was answered (in-app, from the notification, or
@@ -477,7 +488,7 @@ open class AppContainer private constructor(
477488 }
478489 }
479490 if (wasRunning && !isActivelyViewing(esid)) {
480− appScope.launch { notifySessionError(esid) }
491+ appScope.launch { notifySessionError(esid, conn.repository) }
481492 }
482493 return@collect
483494 }
@@ -502,7 +513,7 @@ open class AppContainer private constructor(
502513 // the shared-event collector here would stall the SharedFlow,
503514 // overflowing its DROP_OLDEST buffer and silently dropping live
504515 // parts/updates for every other subscriber (e.g. the message reducer).
505− appScope.launch { notifySessionCompleted(idleSid) }
516+ appScope.launch { notifySessionCompleted(idleSid, conn.repository) }
506517 }
507518 return@collect
508519 }
@@ -597,15 +608,18 @@ open class AppContainer private constructor(
597608 const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
598609 }
599610
600− /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id. */
601− private suspend fun resolveSessionTitle(sessionId: String): String =
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). */
615+ private suspend fun resolveSessionTitle(sessionId: String, originRepo: SessionRepository): String =
602616 runCatchingCancellable {
603− activeConnection.value?.repository?.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
617+ originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
604618 }.getOrNull() ?: sessionId
605619
606620 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
607− private suspend fun notifySessionCompleted(sessionId: String) {
608− val title = resolveSessionTitle(sessionId)
621+ private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository) {
622+ val title = resolveSessionTitle(sessionId, originRepo)
609623 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
610624 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
611625 // If the user opened the session in that gap, setCurrentSession already cancelled the
@@ -615,18 +629,20 @@ open class AppContainer private constructor(
615629 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
616630 }
617631
618− /** Post a heads-up permission notification for [permission], resolving its session title. */
619− private suspend fun notifyPermission(permission: Permission) {
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. */
635+ private suspend fun notifyPermission(permission: Permission, originConn: OpencodeConnection) {
620636 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
621− val title = resolveSessionTitle(sessionId)
637+ val title = resolveSessionTitle(sessionId, originConn.repository)
622638 // Re-check viewing just before posting — see notifySessionCompleted for the race.
623639 if (isActivelyViewing(sessionId)) return
624− appContext?.let { SessionNotifications.postPermission(it, permission, title) }
640+ appContext?.let { SessionNotifications.postPermission(it, permission, title, originConn.profile.id) }
625641 }
626642
627643 /** Post an error notification for a failed background run. */
628− private suspend fun notifySessionError(sessionId: String) {
629− val title = resolveSessionTitle(sessionId)
644+ private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository) {
645+ val title = resolveSessionTitle(sessionId, originRepo)
630646 // Re-check viewing just before posting — see notifySessionCompleted for the race.
631647 if (isActivelyViewing(sessionId)) return
632648 appContext?.let { SessionNotifications.postError(it, sessionId, title) }
@@ -638,11 +654,19 @@ open class AppContainer private constructor(
638654 * connection existed AND respondPermission succeeded — the receiver uses it to decide
639655 * whether to dismiss the notification. A missing connection makes the respond a no-op,
640656 * 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. */
642665 open fun respondToPermissionFromNotification(
643666 sessionId: String,
644667 permissionId: String,
645668 response: PermissionResponse,
669+ profileId: String?,
646670 onDone: (Boolean) -> Unit,
647671 ) {
648672 appScope.launch {
@@ -653,8 +677,12 @@ open class AppContainer private constructor(
653677 var success = false
654678 try {
655679 success = runCatchingCancellable {
656− val api = activeConnection.value?.api ?: return@runCatchingCancellable false
657− api.respondPermission(sessionId, permissionId, response)
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
685+ conn.api.respondPermission(sessionId, permissionId, response)
658686 true
659687 }.onFailure {
660688 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() {
2828 when (intent.action) {
2929 ACTION_PERMISSION -> {
3030 val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
31+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
3132 val response = PermissionResponse.entries.firstOrNull {
3233 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
3334 } ?: return
3435 val pending = goAsync()
35− container.respondToPermissionFromNotification(sessionId, permissionId, response) { success ->
36+ container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
3637 // Only dismiss the notification once the tool was actually answered. If
3738 // there was no live connection (respond is a no-op) or the call failed,
3839 // leave it up so the user can retry — otherwise it vanishes with the
@@ -62,6 +63,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
6263 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
6364 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
6465 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
66+ const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
6567 const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
6668 const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
6769 }
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+5−2
@@ -136,9 +136,11 @@ object SessionNotifications {
136136
137137 /** Post a heads-up notification for a permission request with Allow once / Always /
138138 * 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. */
140142 @SuppressLint("MissingPermission")
141− fun postPermission(context: Context, permission: Permission, sessionTitle: String) {
143+ fun postPermission(context: Context, permission: Permission, sessionTitle: String, profileId: String?) {
142144 if (!canPost(context)) return
143145 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
144146 val notifId = notifId(NS_PERMISSION, sessionId)
@@ -169,6 +171,7 @@ object SessionNotifications {
169171 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
170172 putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
171173 putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
174+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
172175 }
173176 val pending = PendingIntent.getBroadcast(
174177 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
88import soy.iko.opencode.R
99import soy.iko.opencode.data.repo.RecentSession
1010import soy.iko.opencode.data.repo.RecentSessionsStore
11+import java.security.MessageDigest
1112
1213/** Serves the list rows for [SessionsWidgetProvider] from [RecentSessionsStore]. */
1314class SessionsWidgetService : RemoteViewsService() {
@@ -42,10 +43,26 @@ private class SessionsWidgetFactory(private val context: Context) : RemoteViewsS
4243
4344 override fun getLoadingView(): RemoteViews? = null
4445 override fun getViewTypeCount(): Int = 1
45− // FIX 17: derive a stable id from the session id (not the position) so prepending a new
46− // session doesn't mis-recycle RemoteViews and show stale row content. Falls back to the
47− // position for an out-of-range index.
48− override fun getItemId(position: Int): Long =
49− items.getOrNull(position)?.id?.hashCode()?.toLong() ?: position.toLong()
46+ // Derive a stable id from the session id via a collision-resistant SHA-256 digest
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.
50+ // Falls back to the position for an out-of-range index.
51+ override fun getItemId(position: Int): Long {
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+ }
5067 override fun hasStableIds(): Boolean = true
5168}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+9−4
@@ -76,10 +76,15 @@ fun OpencodeApp(container: AppContainer) {
7676 var handledShareText by remember { mutableStateOf<String?>(null) }
7777 var handledSharedMedia by remember { mutableStateOf<List<String>>(emptyList()) }
7878 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
79− if (pendingShare == null && pendingSharedMedia.isEmpty()) {
80− handledSharedMedia = emptyList()
81− return@LaunchedEffect
82− }
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.
85+ if (pendingShare == null) handledShareText = null
86+ if (pendingSharedMedia.isEmpty()) handledSharedMedia = emptyList()
87+ if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
8388 if (connection == null) return@LaunchedEffect
8489 val newShare = (pendingShare != null && pendingShare != handledShareText) ||
8590 (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
5555import kotlinx.coroutines.Dispatchers
5656import kotlinx.coroutines.Job
5757import kotlinx.coroutines.delay
58+import kotlinx.coroutines.isActive
59+import kotlinx.coroutines.currentCoroutineContext
5860import kotlinx.coroutines.launch
5961
6062/**
@@ -598,62 +600,77 @@ class ChatViewModel(
598600 }
599601 }
600602 try {
601− conn.events.events.collect { event ->
602− if (SessionRepository.isIdle(event, sessionId)) {
603− _running.value = false
604− runEndedByIdle = true
605− // Auto-send a queued follow-up once the previous run finishes,
606− // so the user's drafted-while-running message isn't lost.
607− val queued = _queuedFollowUp.value
608− if (queued != null) {
609− setQueuedFollowUp(null)
610− send(queued, includeAttachments = false)
611− }
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() }
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 {
612+ conn.events.events.collect { event ->
613+ if (SessionRepository.isIdle(event, sessionId)) {
614+ _running.value = false
615+ runEndedByIdle = true
616+ // Auto-send a queued follow-up once the previous run finishes,
617+ // so the user's drafted-while-running message isn't lost.
618+ val queued = _queuedFollowUp.value
619+ if (queued != null) {
620+ setQueuedFollowUp(null)
621+ send(queued, includeAttachments = false)
622+ }
645623 }
646− is SessionDeleted ->
647− if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
648− _sessionDeleted.value = true
624+ if (SessionRepository.isError(event, sessionId)) {
625+ _running.value = false
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)))
649636 }
650− else -> {}
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+ }
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)
651668 }
652669 }
653670 } catch (e: CancellationException) {
654671 throw e
655672 } catch (e: Exception) {
656− Log.w("ChatViewModel", "SSE event collector error", e)
673+ Log.w("ChatViewModel", "SSE event collector loop error", e)
657674 }
658675 }
659676 }
@@ -704,16 +721,29 @@ class ChatViewModel(
704721 container.activeConnection.collectLatest { conn ->
705722 if (conn == null) return@collectLatest
706723 try {
707− conn.events.state.collect { state ->
708− if ((state == EventStreamClient.ConnectionState.Disconnected ||
709− state == EventStreamClient.ConnectionState.Failed) && _running.value) {
710− _running.value = false
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 {
731+ conn.events.state.collect { state ->
732+ if ((state == EventStreamClient.ConnectionState.Disconnected ||
733+ state == EventStreamClient.ConnectionState.Failed) && _running.value) {
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)
711741 }
712742 }
713743 } catch (e: CancellationException) {
714744 throw e
715745 } catch (e: Exception) {
716− Log.w("ChatViewModel", "SSE state collector error", e)
746+ Log.w("ChatViewModel", "SSE state collector loop error", e)
717747 }
718748 }
719749 }
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+7−2
@@ -17,7 +17,6 @@ import androidx.compose.material3.TextButton
1717import androidx.compose.runtime.Composable
1818import androidx.compose.runtime.mutableStateOf
1919import androidx.compose.runtime.remember
20−import androidx.compose.runtime.saveable.rememberSaveable
2120import androidx.compose.runtime.getValue
2221import androidx.compose.runtime.setValue
2322import androidx.compose.ui.Modifier
@@ -46,7 +45,13 @@ fun PermissionDialog(
4645 // so a null -> permissionB transition can collapse to just permissionB, reusing this
4746 // composable for a different permission. Without the key `responded` would stay true
4847 // and every button (and back-press) would be a no-op — an undismissable modal.
49− var responded by rememberSaveable(permission.id) { mutableStateOf(false) }
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.
54+ var responded by remember(permission.id) { mutableStateOf(false) }
5055 val respond: (PermissionResponse) -> Unit = { response ->
5156 if (!responded) {
5257 responded = true
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+10−3
@@ -77,9 +77,16 @@ class TtsController(context: Context) : RememberObserver {
7777 // distinct id so their per-utterance onDone doesn't clear the Stop state early.
7878 val utteranceId = if (index == chunks.lastIndex) id else "$id#$index"
7979 val result = tts.speak(chunk, queueMode, null, utteranceId)
80− // If even the first chunk can't be enqueued, nothing plays and no callback fires;
81− // bail without marking this message as speaking so the button doesn't stick.
82− if (index == 0 && result != TextToSpeech.SUCCESS) { _speakingId.value = null; return }
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
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.
85+ if (result != TextToSpeech.SUCCESS) {
86+ if (index == 0) _speakingId.value = null
87+ else stop()
88+ return
89+ }
8390 }
8491 _speakingId.value = id
8592 }