git.iko.soy/opencode-android
on master help sign in
Fix NUL-byte follow-up prefix, outbox loss on 401/403, stale notifications, two-pane shared media, and autoConnect clobber race
commit49dff6cbbef4a646b818eaedf0d9aebd95617cd7 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 17:44 (3 months ago) parentfac92bb
3 files changed+33−5
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+1−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+24−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+8−2
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+1−1
@@ -255,7 +255,7 @@ open class DraftStore private constructor(
255255 /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
256256 * prefs file. The leading/trailing spaces can't collide with a real opencode session
257257 * id (which are non-blank, space-free tokens like "ses_..."). */
258− const val FOLLOWUP_PREFIX = "followup"
258+ const val FOLLOWUP_PREFIX = " followup "
259259 }
260260
261261 /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+24−2
@@ -383,10 +383,15 @@ open class AppContainer private constructor(
383383 // order, so a poison message would otherwise block every later queued message
384384 // for this profile forever. Transient failures (offline again, 5xx, 408/429)
385385 // keep the queue intact and retry on the next trigger.
386+ // 401/403 (bad/expired credentials) are NOT permanent: the message itself is
387+ // deliverable once the user fixes the profile. Dropping it would silently lose
388+ // the user's composed offline prompt on a recoverable auth failure. They stay
389+ // in the queue and retry on the next flush after auth is restored.
386390 val status = result.exceptionOrNull()?.let { responseStatusCode(it) }
387− // Permanent = a non-408/429 4xx, matching withRetry's non-retryable rule.
391+ // Permanent = a non-408/429/401/403 4xx, matching withRetry's non-retryable rule
392+ // except for auth failures, which are recoverable by re-editing the profile.
388393 val permanent = when (status) {
389− null, 408, 429 -> false
394+ null, 408, 429, 401, 403 -> false
390395 in 400..499 -> true
391396 else -> false
392397 }
@@ -605,6 +610,12 @@ open class AppContainer private constructor(
605610 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
606611 private suspend fun notifySessionCompleted(sessionId: String) {
607612 val title = resolveSessionTitle(sessionId)
613+ // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
614+ // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
615+ // If the user opened the session in that gap, setCurrentSession already cancelled the
616+ // prior notification — posting a fresh one here would leave a "session ready" heads-up
617+ // lingering while they're already looking at the finished run.
618+ if (isActivelyViewing(sessionId)) return
608619 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
609620 }
610621
@@ -612,12 +623,16 @@ open class AppContainer private constructor(
612623 private suspend fun notifyPermission(permission: Permission) {
613624 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
614625 val title = resolveSessionTitle(sessionId)
626+ // Re-check viewing just before posting — see notifySessionCompleted for the race.
627+ if (isActivelyViewing(sessionId)) return
615628 appContext?.let { SessionNotifications.postPermission(it, permission, title) }
616629 }
617630
618631 /** Post an error notification for a failed background run. */
619632 private suspend fun notifySessionError(sessionId: String) {
620633 val title = resolveSessionTitle(sessionId)
634+ // Re-check viewing just before posting — see notifySessionCompleted for the race.
635+ if (isActivelyViewing(sessionId)) return
621636 appContext?.let { SessionNotifications.postError(it, sessionId, title) }
622637 }
623638
@@ -694,6 +709,13 @@ open class AppContainer private constructor(
694709 val recent = runCatchingCancellable { profileStore.profiles.first() }
695710 .getOrDefault(emptyList())
696711 .firstOrNull() ?: return
712+ // The profile read above suspends on DataStore. A manual connect() to a different
713+ // server during that window establishes a connection the user explicitly chose; we
714+ // must not clobber it by connecting to `recent` on top. connect() unconditionally
715+ // closes the active connection, so guard here: if anything is already active, the
716+ // user (or another connector) already took the cold-start slot — abort autoConnect.
717+ // (disconnectIf below similarly guards the failed-ping teardown by identity.)
718+ if (_activeConnection.value != null) return
697719 _reconnecting.value = true
698720 var conn: OpencodeConnection? = null
699721 val ok = runCatchingCancellable {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+8−2
@@ -373,8 +373,14 @@ fun ChatScreen(
373373 }
374374 }
375375
376− // Stage images shared into the app (via the system share sheet) as attachments, once.
377− LaunchedEffect(Unit) {
376+ // Stage images shared into the app (via the system share sheet) as attachments.
377+ // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives
378+ // while this ChatScreen is already composed — which happens in two-pane mode, where the
379+ // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would
380+ // only fire on first composition. consumePendingSharedMedia() atomically drains the
381+ // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op.
382+ val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
383+ LaunchedEffect(pendingSharedMedia) {
378384 val shared = container.consumePendingSharedMedia()
379385 if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
380386 }