git.iko.soy/opencode-android
on master help sign in
Fix 18 bugs: offline detection, outbox wipe, reply wrong-server, SSE silent, mDNS re-appear, queue UI, RemoteImage blank, HTTP auth leak, signal loss on death, Compose state collision, stuck reply field, undoDelete flash, autoConnect TOCTOU, AppLockGate prompt race, MarkdownText throttle, TimeFormat verbose, branchEvents key, SessionDeleted cancel, model resilience
commit807ef7778572f93074d9b3c6984afeb069a9a706 authoriko <mail@iko.soy> dateFri 3 Jul 2026, 02:38 (3 months ago) parent59aa992
23 files changed+398−219
app/src/main/java/soy/iko/opencode/MainActivity.kt+15−3 app/src/main/java/soy/iko/opencode/data/model/Agent.kt+7−2 app/src/main/java/soy/iko/opencode/data/model/Message.kt+6−2 app/src/main/java/soy/iko/opencode/data/model/Permission.kt+4−3 app/src/main/java/soy/iko/opencode/data/model/Provider.kt+4−1 app/src/main/java/soy/iko/opencode/data/model/Session.kt+4−1 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+26−17 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+8 app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+11−4 app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+19−9 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+74−49 app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+10−2 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+5−2 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+12−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+43−18 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+19−4 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+17−16 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+37−14 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+10−26 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+5−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+60−43 app/src/main/res/values-es/strings.xml+1 app/src/main/res/values/strings.xml+1
app/src/main/java/soy/iko/opencode/MainActivity.kt+15−3
@@ -211,9 +211,21 @@ class MainActivity : FragmentActivity() {
211211
212212 override fun onSaveInstanceState(outState: Bundle) {
213213 super.onSaveInstanceState(outState)
214− outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
215− outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
216− outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled)
214+ val container = (application as OpencodeApp).container
215+ // Persist each intent-handled flag as "consumed", not merely "dispatched". The pending
216+ // signals live only in the in-memory AppContainer, which is recreated EMPTY on process
217+ // death. Persisting a plain "dispatched = true" would, after a kill *before* the UI
218+ // consumed the signal, restore the flag as true so the fresh container never receives it —
219+ // the deep-linked / new / shared session would silently never open. So AND each flag with
220+ // "the container no longer holds the signal": still-pending at save time → persist false so
221+ // the re-delivered intent re-dispatches it into the new container on restore; already
222+ // consumed → persist true so we don't re-open it unexpectedly on every later restore.
223+ val openConsumed = container.pendingOpenSession.value == null
224+ val newConsumed = !container.pendingNewSession.value
225+ val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
226+ outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
227+ outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
228+ outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
217229 outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
218230 }
219231
app/src/main/java/soy/iko/opencode/data/model/Agent.kt+7−2
@@ -7,7 +7,10 @@ import kotlinx.serialization.Serializable
77@Immutable
88@Serializable
99data class Agent(
10− val name: String,
10+ // Defaulted so one name-less element in GET /agent doesn't fail the whole list decode
11+ // (emptying the agent picker) — matches the resilient-decoding philosophy. A blank name is
12+ // degraded but recoverable; a thrown decode is not.
13+ val name: String = "",
1114 val description: String? = null,
1215 val mode: String? = null,
1316 val builtIn: Boolean = false,
@@ -21,7 +24,9 @@ data class Agent(
2124@Immutable
2225@Serializable
2326data class Command(
24− val name: String,
27+ // Defaulted so one name-less element in GET /command doesn't fail the whole list decode
28+ // (emptying the command picker) — matches the resilient-decoding philosophy.
29+ val name: String = "",
2530 val description: String? = null,
2631 val agent: String? = null,
2732 val model: String? = null,
app/src/main/java/soy/iko/opencode/data/model/Message.kt+6−2
@@ -3,7 +3,7 @@ package soy.iko.opencode.data.model
33import androidx.compose.runtime.Immutable
44import kotlinx.serialization.SerialName
55import kotlinx.serialization.Serializable
6−import kotlinx.serialization.json.JsonObject
6+import kotlinx.serialization.json.JsonElement
77import kotlinx.serialization.json.JsonClassDiscriminator
88
99/**
@@ -39,7 +39,11 @@ data class AssistantMessage(
3939 val modelID: String? = null,
4040 val cost: Double? = null,
4141 val tokens: Tokens? = null,
42− val error: JsonObject? = null,
42+ // JsonElement (not JsonObject) so a server that sends `error` as a bare string/array — or any
43+ // non-object shape — decodes instead of throwing mid-AssistantMessage (the MessageInfo
44+ // polymorphic fallback can't catch that, since `role` already matched). Mirrors
45+ // SessionError.error. Not currently read by the UI, but kept tolerant for forward-compat.
46+ val error: JsonElement? = null,
4347 override val time: TimeInfo? = null,
4448) : MessageInfo {
4549 /** A run is complete once the server stamps a completion time. */
app/src/main/java/soy/iko/opencode/data/model/Permission.kt+4−3
@@ -3,7 +3,6 @@ package soy.iko.opencode.data.model
33import androidx.compose.runtime.Immutable
44import kotlinx.serialization.Serializable
55import kotlinx.serialization.json.JsonElement
6−import kotlinx.serialization.json.JsonObject
76import kotlinx.serialization.json.JsonArray
87import kotlinx.serialization.json.JsonPrimitive
98
@@ -15,14 +14,16 @@ import kotlinx.serialization.json.JsonPrimitive
1514@Immutable
1615@Serializable
1716data class Permission(
18− val id: String,
17+ val id: String = "",
1918 val sessionID: String = "",
2019 val type: String? = null,
2120 val pattern: JsonElement? = null,
2221 val messageID: String? = null,
2322 val callID: String? = null,
2423 val title: String? = null,
25− val metadata: JsonObject? = null,
24+ // JsonElement (not JsonObject) so a non-object `metadata` shape decodes instead of throwing,
25+ // matching [pattern]. Not read by the UI today, but kept tolerant for forward-compat.
26+ val metadata: JsonElement? = null,
2627 val time: TimeInfo? = null,
2728) {
2829 val patternText: String?
app/src/main/java/soy/iko/opencode/data/model/Provider.kt+4−1
@@ -14,7 +14,10 @@ data class ProvidersResponse(
1414@Immutable
1515@Serializable
1616data class Provider(
17− val id: String,
17+ // Defaulted so one id-less element in GET /config/providers doesn't fail the whole list decode
18+ // (emptying the model picker) — matches MessageInfo's defaulted id and the resilient-decoding
19+ // philosophy. A blank id is degraded but recoverable; a thrown decode is not.
20+ val id: String = "",
1821 val name: String? = null,
1922 val models: Map<String, ModelInfo> = emptyMap(),
2023) {
app/src/main/java/soy/iko/opencode/data/model/Session.kt+4−1
@@ -7,7 +7,10 @@ import kotlinx.serialization.Serializable
77@Immutable
88@Serializable
99data class Session(
10− val id: String,
10+ // Defaulted so one id-less element in GET /session doesn't fail the whole list decode
11+ // (emptying the session list) — matches MessageInfo's defaulted id and the resilient-decoding
12+ // philosophy. A blank id is degraded but recoverable; a thrown decode is not.
13+ val id: String = "",
1114 val parentID: String? = null,
1215 val title: String? = null,
1316 val version: String? = null,
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+26−17
@@ -4,13 +4,15 @@ import soy.iko.opencode.data.model.ServerProfile
44import io.ktor.client.HttpClient
55import io.ktor.client.engine.okhttp.OkHttp
66import io.ktor.client.plugins.HttpTimeout
7+import io.ktor.client.plugins.api.Send
8+import io.ktor.client.plugins.api.createClientPlugin
79import io.ktor.client.plugins.auth.Auth
810import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
911import io.ktor.client.plugins.auth.providers.basic
1012import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
1113import io.ktor.client.plugins.defaultRequest
1214import io.ktor.client.plugins.sse.SSE
13−import io.ktor.client.request.header
15+import io.ktor.http.HttpHeaders
1416import io.ktor.http.URLBuilder
1517import io.ktor.http.takeFrom
1618import io.ktor.serialization.kotlinx.json.json
@@ -101,27 +103,34 @@ object HttpClientFactory {
101103 }
102104 }
103105 }
104− } else {
105− // For HTTP profiles with auth, send credentials proactively in the
106− // Authorization header via defaultRequest (below) so the user's intent
107− // to connect over cleartext is honored, but we never install the Auth
108− // plugin's reactive challenge-response which would silently re-send
109− // credentials on any 401 without checking the protocol.
106+ } else if (pinHost != null) {
107+ // For HTTP profiles with auth, attach the Basic header proactively (the reactive
108+ // Auth plugin is skipped for non-HTTPS so a 401 can't silently re-send credentials
109+ // in cleartext), but scope it to the configured host and re-check on EVERY physical
110+ // send. Redirects matter: Ktor builds the redirect request by copying the original
111+ // headers, so an unconditional header would carry the cleartext credentials to a
112+ // cross-host redirect target. Setting it here (and stripping it off-host) mirrors
113+ // the HTTPS path's sendWithoutRequest host guard so credentials never go off-origin.
114+ val credentials = java.util.Base64.getEncoder().encodeToString(
115+ "${profile.username.orEmpty()}:${profile.password.orEmpty()}".toByteArray(),
116+ )
117+ install(
118+ createClientPlugin("HttpBasicAuthHostScoped") {
119+ on(Send) { request ->
120+ if (request.url.host.equals(pinHost, ignoreCase = true)) {
121+ request.headers[HttpHeaders.Authorization] = "Basic $credentials"
122+ } else {
123+ request.headers.remove(HttpHeaders.Authorization)
124+ }
125+ proceed(request)
126+ }
127+ },
128+ )
110129 }
111130 }
112131
113132 defaultRequest {
114133 url.takeFrom(URLBuilder().takeFrom(normalizedUrl))
115− // For HTTP profiles with auth, attach the Basic auth header proactively on
116− // every request (the Auth plugin is skipped for non-HTTPS). The user chose
117− // cleartext explicitly, so we honor that — but without the reactive challenge
118− // logic that could re-send credentials silently.
119− if (profile.hasAuth && !isHttps) {
120− val credentials = java.util.Base64.getEncoder().encodeToString(
121− "${profile.username.orEmpty()}:${profile.password.orEmpty()}".toByteArray(),
122− )
123− header("Authorization", "Basic $credentials")
124− }
125134 }
126135 }
127136 }
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+8
@@ -40,6 +40,14 @@ object NetworkConfig {
4040 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
4141 const val sseEventBufferCapacity = 64
4242
43+ // --- Connectivity monitor (AppContainer) ---
44+
45+ /** Grace period after a network is lost before reporting offline. Absorbs the transient
46+ * window during a Wi-Fi→cellular handoff where onLost(WiFi) fires before the replacement
47+ * network becomes active (cm.activeNetwork is briefly null), so a handoff doesn't flash
48+ * offline while a genuine loss (no active network after the grace) is still caught. */
49+ const val networkOfflineGraceMs = 2_000L
50+
4351 // --- Catalog cache (OpencodeApiClient) ---
4452
4553 /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+11−4
@@ -93,10 +93,17 @@ open class NsdDiscovery(context: Context?) {
9393 }
9494 override fun onServiceLost(serviceInfo: NsdServiceInfo) {
9595 val name = serviceInfo.serviceName ?: return
96− // Mark the name lost so the resolver skips it if it hasn't been processed yet.
97− // If it's already in `found` (resolved before the lost), remove and emit now.
98− lostPending.add(name)
99− if (found.remove(name) != null) trySend(found.values.sortedBy { it.name })
96+ // If it was already resolved and in `found`, remove and emit now — and do NOT set a
97+ // lostPending marker: the resolver already processed this name, so the marker would
98+ // never be consumed and would instead suppress the service's NEXT re-appearance (a
99+ // restarted server re-discovered later would hit `lostPending.remove` and be
100+ // dropped). Only mark names lost *before* the serialized resolver reached them —
101+ // those aren't in `found` yet, and the marker tells the resolver to skip the insert.
102+ if (found.remove(name) != null) {
103+ trySend(found.values.sortedBy { it.name })
104+ } else {
105+ lostPending.add(name)
106+ }
100107 }
101108 override fun onDiscoveryStopped(serviceType: String?) {}
102109 override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+19−9
@@ -74,22 +74,25 @@ open class OutboxStore private constructor(
7474
7575 /** Read the on-disk queue into memory. MUST be called while holding [ioMutex]. Idempotent
7676 * (guarded by [loaded]) so a [mutate] that arrives before [load] can call it to guarantee
77− * disk is read before any write, then a later [load] is a no-op. */
78− private suspend fun loadLocked() {
79− if (loaded) return
77+ * disk is read before any write, then a later [load] is a no-op. Returns true once the queue
78+ * is in memory (or the file is genuinely absent), false if an existing file couldn't be
79+ * read — the caller MUST NOT persist on false or it would overwrite the still-valid queue. */
80+ private suspend fun loadLocked(): Boolean {
81+ if (loaded) return true
8082 val f = file?.takeIf { it.exists() }
8183 if (f == null) {
8284 // A genuinely absent file is a normal empty queue: mark loaded so mutations proceed.
8385 loaded = true
84− return
86+ return true
8587 }
8688 // Only mark [loaded] AFTER a successful read. A transient read/decode failure (or a
8789 // cancelled read) of an *existing* file must not clobber the still-valid on-disk queue:
88− // leave loaded=false and return so a later mutate/load retries the read before any write,
89− // instead of persisting an empty snapshot over the user's offline-composed messages.
90+ // leave loaded=false and report failure so the caller skips its write and a later
91+ // mutate/load retries the read, instead of persisting an empty snapshot over the user's
92+ // offline-composed messages.
9093 val diskList = withContext(Dispatchers.IO) {
9194 runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
92− }.getOrElse { return }
95+ }.getOrElse { return false }
9396 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
9497 // the in-memory version on conflict (it's newer).
9598 if (_messages.value.isEmpty()) {
@@ -104,6 +107,7 @@ open class OutboxStore private constructor(
104107 persist(merged)
105108 }
106109 loaded = true
110+ return true
107111 }
108112
109113 open suspend fun enqueue(message: OutboxMessage) = mutate { current ->
@@ -127,10 +131,16 @@ open class OutboxStore private constructor(
127131 // Ensure the persisted queue has been read into memory before mutating: a mutation
128132 // that races ahead of load() would otherwise transform an empty in-memory snapshot
129133 // and persist()/delete() the file, destroying messages queued in a previous run.
130− loadLocked()
134+ val loadedOk = loadLocked()
131135 val updated = transform(_messages.value).sortedBy { it.createdAt }
132136 _messages.value = updated
133− persist(updated)
137+ // Only write when the existing on-disk queue was actually read. If the read of an
138+ // existing file failed (loadedOk == false), _messages is missing whatever it held, so
139+ // persisting `updated` would overwrite the still-valid file with an incomplete
140+ // snapshot — losing the user's prior offline-composed messages. Keep the transform in
141+ // memory (so the UI reflects the new item) but leave disk untouched; loaded stays
142+ // false, so the next mutate/load retries the read and then merges + persists.
143+ if (loadedOk) persist(updated)
134144 }
135145 }
136146
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+74−49
@@ -513,7 +513,7 @@ open class AppContainer private constructor(
513513 // the shared-event collector here would stall the SharedFlow,
514514 // overflowing its DROP_OLDEST buffer and silently dropping live
515515 // parts/updates for every other subscriber (e.g. the message reducer).
516− appScope.launch { notifySessionCompleted(idleSid, conn.repository) }
516+ appScope.launch { notifySessionCompleted(idleSid, conn.repository, conn.profile.id) }
517517 }
518518 return@collect
519519 }
@@ -617,8 +617,11 @@ open class AppContainer private constructor(
617617 originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
618618 }.getOrNull() ?: sessionId
619619
620− /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
621− private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository) {
620+ /** Resolve the title for [sessionId] (best-effort) and post a completion notification.
621+ * [originProfileId] is the id of the server that ran the session; it's embedded in the
622+ * inline-reply action so a follow-up routes back to THIS server even after the user switches
623+ * connections (mirrors [notifyPermission]). */
624+ private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
622625 val title = resolveSessionTitle(sessionId, originRepo)
623626 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
624627 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
@@ -626,7 +629,7 @@ open class AppContainer private constructor(
626629 // prior notification — posting a fresh one here would leave a "session ready" heads-up
627630 // lingering while they're already looking at the finished run.
628631 if (isActivelyViewing(sessionId)) return
629− appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
632+ appContext?.let { SessionNotifications.postCompleted(it, sessionId, title, originProfileId) }
630633 }
631634
632635 /** Post a heads-up permission notification for [permission], resolving its session title.
@@ -701,15 +704,19 @@ open class AppContainer private constructor(
701704 * immediate send when a connection is live, so an online reply still goes out now. [onDone]
702705 * reports whether the reply was durably enqueued so the receiver only dismisses the
703706 * notification on success. */
704− open fun sendPromptFromNotification(sessionId: String, text: String, onDone: (Boolean) -> Unit) {
707+ open fun sendPromptFromNotification(sessionId: String, text: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
705708 appScope.launch {
706709 // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
707710 // scope is cancelled mid-call — see respondToPermissionFromNotification above.
708711 var enqueued = false
709712 try {
710− // Attribute to the active (or most-recent) profile so a reconnect to a different
711− // server doesn't misfire this against a session id that only exists elsewhere.
712− val profileId = activeConnection.value?.profile?.id
713+ // Attribute to the profile that POSTED the notification (embedded in the reply
714+ // intent) so the follow-up is always enqueued against the server that owns
715+ // [sessionId] — never whichever server happens to be active now, which would POST
716+ // A's session id to B, 404, and silently drop the reply. Fall back to the active/
717+ // most-recent profile only for a legacy intent that predates the embedded id.
718+ val profileId = originProfileId
719+ ?: activeConnection.value?.profile?.id
713720 ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
714721 enqueued = if (profileId == null) {
715722 false
@@ -748,28 +755,24 @@ open class AppContainer private constructor(
748755 .getOrDefault(emptyList())
749756 .firstOrNull() ?: return
750757 // The profile read above suspends on DataStore. A manual connect() to a different
751− // server during that window establishes a connection the user explicitly chose; we
752− // must not clobber it by connecting to `recent` on top. connect() unconditionally
753− // closes the active connection, so guard here: if anything is already active, the
754− // user (or another connector) already took the cold-start slot — abort autoConnect.
755− // (disconnectIf below similarly guards the failed-ping teardown by identity.)
756− if (_activeConnection.value != null) return
758+ // server during that window establishes a connection the user explicitly chose; we must
759+ // not clobber it by connecting to `recent` on top. connectIfIdle() checks "is anything
760+ // active?" and connects atomically under connectionMutex, returning null if the cold-start
761+ // slot was already taken — closing the TOCTOU window an unlocked null-check plus connect()'s
762+ // unconditional close would leave. (disconnectIf below similarly guards the failed-ping
763+ // teardown by identity.)
757764 _reconnecting.value = true
758765 var conn: OpencodeConnection? = null
759766 val ok = runCatchingCancellable {
760− conn = connect(recent)
767+ conn = connectIfIdle(recent) ?: return@runCatchingCancellable false
761768 conn!!.api.ping()
762− }.isSuccess
769+ true
770+ }.getOrDefault(false)
763771 _reconnecting.value = false
764− if (ok) {
765− _autoConnectDone.value = true
766− } else {
767− val created = conn
768− // Only tear down the connection we created: a manual connect to a different server
769− // during the ping window may have already replaced it, and disconnect() closes
770− // whatever is currently active — which would silently drop that new connection.
771− // disconnectIf re-checks identity under the mutex to close the check-then-close race.
772− if (created != null) disconnectIf(created)
772+ when {
773+ conn == null -> Unit // slot already taken by a manual connect — leave it be
774+ ok -> _autoConnectDone.value = true
775+ else -> disconnectIf(conn!!)
773776 }
774777 }
775778
@@ -788,12 +791,21 @@ open class AppContainer private constructor(
788791 _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
789792 val callback = object : ConnectivityManager.NetworkCallback() {
790793 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
791− // Don't flip offline on onLost: during a Wi-Fi→cellular handoff, onLost(WiFi) fires
792− // before onAvailable(cellular), and cm.activeNetwork is transiently null in that
793− // window — eagerly marking offline would skip outbox flushing and flash an offline
794− // indicator. onUnavailable (no networks left at all) is the reliable offline signal.
795− override fun onLost(network: Network) { /* handled by onUnavailable */ }
796− override fun onUnavailable() { _isOnline.value = false }
794+ // A matched (internet-capable) network was lost. Don't eagerly flip offline: during a
795+ // Wi-Fi→cellular handoff onLost(WiFi) fires before onAvailable(cellular) and
796+ // cm.activeNetwork is transiently null in that window — flashing offline there would
797+ // skip outbox flushing and blink the indicator. Instead re-check activeNetwork after a
798+ // short grace period and only report offline if there's still no active network.
799+ //
800+ // This MUST live in onLost, not onUnavailable: onUnavailable is delivered only for
801+ // requestNetwork() with a timeout, never for registerNetworkCallback() — relying on it
802+ // (as this once did) meant offline was never detected after the first onAvailable.
803+ override fun onLost(network: Network) {
804+ appScope.launch {
805+ delay(NetworkConfig.networkOfflineGraceMs)
806+ _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
807+ }
808+ }
797809 }
798810 // Only match networks that actually provide internet — a capability-less request
799811 // fires onAvailable for transports that can't reach the server (and would trigger
@@ -810,26 +822,39 @@ open class AppContainer private constructor(
810822 * Only persists the updated `lastUsed` timestamp if it's stale (older than a
811823 * minute), so rapid reconnect storms don't each trigger a DataStore write. */
812824 open suspend fun connect(profile: ServerProfile): OpencodeConnection =
825+ connectionMutex.withLock { connectLocked(profile) }
826+
827+ /** Connect to [profile] only if nothing is currently active, checking-and-connecting
828+ * atomically under [connectionMutex]. Returns null (touching nothing) when a connection is
829+ * already active, so autoConnect() can't clobber a manual connect() the user made during its
830+ * suspending profile read — closing the TOCTOU window an unlocked null-check would leave. */
831+ private suspend fun connectIfIdle(profile: ServerProfile): OpencodeConnection? =
813832 connectionMutex.withLock {
814− _activeConnection.value?.close()
815− _activeConnection.value = null
816− // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
817− // event handler adds ids under the same lock, so an unlocked clear() here could
818− // interleave with an add()+flag=true and leave _anyRunActive pinned true with a torn-
819− // down connection and no SessionIdle to clear it — a stuck foreground service.
820− synchronized(activeRuns) {
821− activeRuns.clear()
822− _anyRunActive.value = false
823− }
824− _unread.value = emptyMap()
825− unreadMessageIds.clear()
826− val now = System.currentTimeMillis()
827− val resolved = profileStore.resolve(profile)
828− val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
829− val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
830− if (needsSave) profileStore.save(finalProfile)
831− OpencodeConnection(finalProfile, messageCacheStore).also { _activeConnection.value = it }
833+ if (_activeConnection.value != null) null else connectLocked(profile)
834+ }
835+
836+ /** Build and install a connection to [profile], replacing any current one. Caller MUST hold
837+ * [connectionMutex]. */
838+ private suspend fun connectLocked(profile: ServerProfile): OpencodeConnection {
839+ _activeConnection.value?.close()
840+ _activeConnection.value = null
841+ // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
842+ // event handler adds ids under the same lock, so an unlocked clear() here could
843+ // interleave with an add()+flag=true and leave _anyRunActive pinned true with a torn-
844+ // down connection and no SessionIdle to clear it — a stuck foreground service.
845+ synchronized(activeRuns) {
846+ activeRuns.clear()
847+ _anyRunActive.value = false
832848 }
849+ _unread.value = emptyMap()
850+ unreadMessageIds.clear()
851+ val now = System.currentTimeMillis()
852+ val resolved = profileStore.resolve(profile)
853+ val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
854+ val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
855+ if (needsSave) profileStore.save(finalProfile)
856+ return OpencodeConnection(finalProfile, messageCacheStore).also { _activeConnection.value = it }
857+ }
833858
834859 open suspend fun disconnect() {
835860 // Acquire the connection mutex so disconnect is serialized with connect().
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+10−2
@@ -55,12 +55,20 @@ class NotificationActionReceiver : BroadcastReceiver() {
5555 ACTION_REPLY -> {
5656 val text = RemoteInput.getResultsFromIntent(intent)
5757 ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
58− ?.takeIf { it.isNotEmpty() } ?: return
58+ ?.takeIf { it.isNotEmpty() }
59+ if (text == null) {
60+ // The RemoteInput fired but the reply was empty/whitespace. SystemUI locks the
61+ // inline field in a "sending…" state until the notification is updated or
62+ // cancelled — cancel it so the field doesn't stay stuck with nothing to send.
63+ SessionNotifications.cancel(context, sessionId)
64+ return
65+ }
66+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
5967 val pending = goAsync()
6068 val finished = AtomicBoolean(false)
6169 val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
6270 watchdog(finishOnce)
63− container.sendPromptFromNotification(sessionId, text) { enqueued ->
71+ container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
6472 // The reply is durably queued (and flushed now if online); clear the
6573 // "session ready" notification. If the enqueue failed, leave it up so the
6674 // reply isn't silently lost and the user can retry.
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+5−2
@@ -92,19 +92,22 @@ object SessionNotifications {
9292 // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
9393 // check through the helper, so suppress its MissingPermission flag on these posts.
9494 @SuppressLint("MissingPermission")
95− fun postCompleted(context: Context, sessionId: String, title: String) {
95+ fun postCompleted(context: Context, sessionId: String, title: String, profileId: String?) {
9696 if (!canPost(context)) return
9797 val notifId = notifId(NS_COMPLETED, sessionId)
9898
9999 // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
100100 // NotificationActionReceiver. The PendingIntent MUST be mutable so the system can
101− // fill in the RemoteInput results before delivering it.
101+ // fill in the RemoteInput results before delivering it. [profileId] is embedded so the
102+ // receiver enqueues the reply against the server that ran the session, not whichever
103+ // server is active when the user taps (mirrors postPermission).
102104 val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
103105 .setLabel(context.getString(R.string.notif_reply_hint))
104106 .build()
105107 val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
106108 action = NotificationActionReceiver.ACTION_REPLY
107109 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
110+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
108111 }
109112 val replyPending = PendingIntent.getBroadcast(
110113 context, notifId, replyIntent,
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+12−2
@@ -118,20 +118,30 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
118118 // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
119119 // BiometricPrompts, which throws.
120120 var authInFlight by remember { mutableStateOf(false) }
121+ // Monotonic id for the current prompt. The system dismisses an in-flight BiometricPrompt when
122+ // the app backgrounds and posts its onAuthenticationError asynchronously; without this, that
123+ // stale error could land after ON_START already started a NEW prompt and clear authInFlight
124+ // mid-prompt — letting a recomposition or the "Unlock" button (enabled = !authInFlight) stack
125+ // a second prompt. Each prompt captures its id and only mutates state while it's still current.
126+ val promptId = remember { intArrayOf(0) }
121127
122128 fun authenticate() {
123129 if (authInFlight || unlocked) return
124130 authInFlight = true
131+ val myPrompt = ++promptId[0]
125132 val executor = ContextCompat.getMainExecutor(activity)
126133 val callback = object : BiometricPrompt.AuthenticationCallback() {
127134 override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
135+ if (myPrompt != promptId[0]) return
128136 authInFlight = false
129137 unlocked = true
130138 }
131139
132140 override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
133141 // User cancelled / lockout / hardware error: stay locked and let them retry
134− // via the button rather than looping the system prompt.
142+ // via the button rather than looping the system prompt. Ignore a stale error
143+ // from a prompt already superseded by a newer one (see promptId above).
144+ if (myPrompt != promptId[0]) return
135145 authInFlight = false
136146 }
137147
@@ -145,7 +155,7 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
145155 .setAllowedAuthenticators(lockAuthenticators)
146156 .build()
147157 runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
148− .onFailure { authInFlight = false }
158+ .onFailure { if (myPrompt == promptId[0]) authInFlight = false }
149159 }
150160
151161 // Re-lock when the app is backgrounded, and re-prompt when it returns to the foreground.
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+43−18
@@ -365,8 +365,11 @@ fun ChatScreen(
365365 }
366366
367367 // Navigate to a session freshly branched from a message (branchFrom), once it's created.
368+ // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession
369+ // lambda would cancel/restart this collector whenever the lambda's identity changes, risking
370+ // a dropped branch-navigation event in the gap.
368371 val branchedMsg = stringResource(R.string.branch_created)
369− LaunchedEffect(onOpenSession) {
372+ LaunchedEffect(Unit) {
370373 vm.branchEvents.collect { newId ->
371374 showToast(shareContext, branchedMsg)
372375 onOpenSession?.invoke(newId)
@@ -1232,6 +1235,7 @@ private fun ChatInputBar(
12321235 canSend = enabled && hasContent,
12331236 onSend = onSend,
12341237 onAbort = onAbort,
1238+ onQueue = { onQueueFollowUp(value) },
12351239 )
12361240 }
12371241 }
@@ -1252,8 +1256,9 @@ private fun enterShouldSend(
12521256 else -> ctrl
12531257}
12541258
1255−/** The composer's trailing button: a Stop (with in-flight spinner) while a run is active,
1256− * otherwise Send. Extracted so [ChatInputBar] stays under the complexity threshold. */
1259+/** The composer's trailing button: while a run is active a Stop (with in-flight spinner),
1260+ * preceded by a Queue button when the user has typed a follow-up; otherwise Send. Extracted so
1261+ * [ChatInputBar] stays under the complexity threshold. */
12571262@Composable
12581263private fun ComposerTrailingButton(
12591264 running: Boolean,
@@ -1261,23 +1266,43 @@ private fun ComposerTrailingButton(
12611266 canSend: Boolean,
12621267 onSend: () -> Unit,
12631268 onAbort: () -> Unit,
1269+ onQueue: () -> Unit,
12641270) {
12651271 if (running) {
1266− // Show a spinner while the abort REST call is in flight so the user sees the stop
1267− // was sent, and disable to prevent a double-tap.
1268− IconButton(
1269− onClick = onAbort,
1270− enabled = !aborting,
1271− modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
1272− ) {
1273− if (aborting) {
1274− val stopLabel = stringResource(R.string.stop)
1275− CircularProgressIndicator(
1276− Modifier.size(18.dp).semantics { contentDescription = stopLabel },
1277− strokeWidth = 2.dp,
1278− )
1279− } else {
1280− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
1272+ Row(verticalAlignment = Alignment.CenterVertically) {
1273+ // While a run is active the trailing action is Stop, so the only send affordance is
1274+ // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
1275+ // That leaves a touch-only user with the setting off no way to queue a follow-up
1276+ // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
1277+ // there's typed content so queuing is always reachable, without changing the user's
1278+ // Enter-inserts-a-newline preference.
1279+ if (canSend) {
1280+ IconButton(
1281+ onClick = onQueue,
1282+ modifier = Modifier.testTag("queue_button"),
1283+ ) {
1284+ Icon(
1285+ Icons.AutoMirrored.Filled.Send,
1286+ contentDescription = stringResource(R.string.queue_followup),
1287+ )
1288+ }
1289+ }
1290+ // Show a spinner while the abort REST call is in flight so the user sees the stop
1291+ // was sent, and disable to prevent a double-tap.
1292+ IconButton(
1293+ onClick = onAbort,
1294+ enabled = !aborting,
1295+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
1296+ ) {
1297+ if (aborting) {
1298+ val stopLabel = stringResource(R.string.stop)
1299+ CircularProgressIndicator(
1300+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
1301+ strokeWidth = 2.dp,
1302+ )
1303+ } else {
1304+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
1305+ }
12811306 }
12821307 }
12831308 } else {
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+19−4
@@ -25,6 +25,7 @@ import androidx.compose.material3.MaterialTheme
2525import androidx.compose.material3.Text
2626import androidx.compose.runtime.Composable
2727import androidx.compose.runtime.getValue
28+import androidx.compose.runtime.key
2829import androidx.compose.runtime.mutableStateOf
2930import androidx.compose.runtime.remember
3031import androidx.compose.runtime.setValue
@@ -168,8 +169,14 @@ private fun UnknownMessageBlock(
168169 modifier = Modifier.padding(start = 6.dp),
169170 )
170171 }
171− for (part in message.parts) {
172− PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
172+ // key() per part so each PartView gets a distinct saveable registry slot: rememberSaveable
173+ // inside PartView keys its RESET on part.id but its registry key is the positional
174+ // compositeKeyHash, which collides for sibling parts in a keyless loop — mis-restoring
175+ // expand/collapse state across parts after process death. (index disambiguates blank ids.)
176+ message.parts.forEachIndexed { index, part ->
177+ key(part.id, index) {
178+ PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
179+ }
173180 }
174181 MessageTimestampText(message.info)
175182 }
@@ -206,7 +213,10 @@ private fun UserBubble(
206213 .padding(12.dp),
207214 verticalArrangement = Arrangement.spacedBy(6.dp),
208215 ) {
209− for (part in message.parts) PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
216+ // key() per part so each PartView gets its own saveable registry slot (see AssistantBlock).
217+ message.parts.forEachIndexed { index, part ->
218+ key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
219+ }
210220 Row(
211221 modifier = Modifier.fillMaxWidth(),
212222 horizontalArrangement = Arrangement.SpaceBetween,
@@ -288,7 +298,12 @@ private fun AssistantBlock(
288298 // isRunning to all of them would keep a completed reasoning block showing the
289299 // "Thinking…" spinner until the whole message finishes.
290300 val partStreaming = isRunning && index == message.parts.lastIndex
291− PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
301+ // key() per part so each PartView gets a distinct saveable registry slot: without it,
302+ // rememberSaveable expand/collapse state (keyed only positionally) is shared across
303+ // sibling parts and mis-restored after process death. (index disambiguates blank ids.)
304+ key(part.id, index) {
305+ PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
306+ }
292307 }
293308 if (info is AssistantMessage) {
294309 val cost = info.cost
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+17−16
@@ -116,25 +116,26 @@ private fun MarkdownBody(
116116 if (streaming) {
117117 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
118118 val markdownState = rememberUpdatedState(markdown)
119− // Keyed to a content-prefix so switching to a different message resets
120− // immediately instead of showing the old message for one frame.
121− val prefix = markdown.take(32)
122− var renderedContent by remember(prefix) { mutableStateOf(markdown) }
123− // conflate() coalesces a burst of token updates into a single emission so the
124− // collector sees only the latest value after the previous delay completes. Plain
125− // collect (not collectLatest) is critical: collectLatest would cancel the delay
126− // on every new value, reintroducing the same starvation the keyed effect had.
127− LaunchedEffect(prefix) {
119+ var renderedContent by remember { mutableStateOf(markdown) }
120+ // A SINGLE long-lived effect (keyed on Unit), per this file's design. Keying on the
121+ // content — even a prefix like markdown.take(32) — cancels and restarts the effect as the
122+ // first characters stream in (the prefix changes on nearly every early token), so the
123+ // in-flight delay never completes and the throttle is defeated at the start of every
124+ // response. A streaming MarkdownBody instance only ever grows its content (a different
125+ // message is a fresh composition with its own remember), so no reset key is needed.
126+ // conflate() coalesces a burst of token updates into a single emission so the collector
127+ // sees only the latest value after the previous delay completes. Plain collect (not
128+ // collectLatest) is critical: collectLatest would cancel the delay on every new value,
129+ // reintroducing the same starvation a keyed effect has.
130+ LaunchedEffect(Unit) {
128131 snapshotFlow { markdownState.value }
129132 .conflate()
130133 .collect { md ->
131− // Only throttle when the content is growing incrementally (streaming):
132− // a content switch or initial render proceeds immediately. Checking
133− // length is O(1) vs. startsWith which is O(n) in the rendered content
134− // length — during a long streaming response this runs every throttle
135− // cycle. The keyed LaunchedEffect(markdown.take(32)) already handles
136− // content switches; within one keyed cycle, growing length means
137− // appending (streaming), not a rewrite.
134+ // Only throttle when the content is growing incrementally (streaming): a
135+ // shorter switch or initial render proceeds immediately. Checking length is
136+ // O(1) vs. startsWith which is O(n) in the rendered content length — during a
137+ // long streaming response this runs every throttle cycle. Within one streaming
138+ // composition, growing length means appending (streaming), not a rewrite.
138139 if (renderedContent.isNotEmpty() &&
139140 md.length > renderedContent.length
140141 ) {
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+37−14
@@ -150,9 +150,20 @@ private fun effectivePort(uri: java.net.URI): Int {
150150@Composable
151151fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
152152 val context = LocalContext.current
153− val model = produceState<Any?>(initialValue = null, part.source, part.url, ctx.baseUrl) {
153+ // Distinguish "still resolving off-thread" from "resolved to nothing loadable": produceState
154+ // seeds a sentinel so the first (pre-resolve) frame renders nothing (as before), but once
155+ // resolveModel returns null — a cross-origin URL (blocked so the server's Basic auth can't
156+ // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
157+ // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
158+ // FileChip to fall through to).
159+ val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl) {
154160 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
155− }.value ?: return
161+ }.value
162+ if (model === ImageResolving) return
163+ if (model == null) {
164+ ImageStatusBox { BrokenImageIcon() }
165+ return
166+ }
156167 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model) {
157168 ImageRequest.Builder(context)
158169 .data(model)
@@ -183,17 +194,29 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
183194 )
184195 }
185196 },
186− error = {
187− Box(
188− modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
189− contentAlignment = Alignment.Center,
190− ) {
191− Icon(
192− Icons.Filled.BrokenImage,
193− contentDescription = stringResource(R.string.image_failed),
194− tint = MaterialTheme.colorScheme.onSurfaceVariant,
195− )
196− }
197− },
197+ error = { ImageStatusBox { BrokenImageIcon() } },
198+ )
199+}
200+
201+/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
202+ * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
203+private val ImageResolving = Any()
204+
205+/** A centered, fixed-min-height box matching the loading/error slots so an image placeholder
206+ * reserves the same space whether it's loading, failed, or unresolvable. */
207+@Composable
208+private fun ImageStatusBox(content: @Composable () -> Unit) {
209+ Box(
210+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
211+ contentAlignment = Alignment.Center,
212+ ) { content() }
213+}
214+
215+@Composable
216+private fun BrokenImageIcon() {
217+ Icon(
218+ Icons.Filled.BrokenImage,
219+ contentDescription = stringResource(R.string.image_failed),
220+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
198221 )
199222}
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+10−26
@@ -27,15 +27,14 @@ import kotlinx.coroutines.delay
2727import java.util.concurrent.TimeUnit
2828
2929/**
30− * Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d").
30+ * Formats an epoch-millis timestamp as a short, compact relative string (e.g. "3m", "2h",
31+ * "5d", "2w", "3mo", "1y").
3132 *
32− * For coarse units (minutes/hours/days) prefers Android's locale-aware
33− * [DateUtils.getRelativeTimeSpanString] so the abbreviations match the device locale
34− * instead of hardcoded English. Falls back to a compact numeric form (e.g. "5m",
35− * "3h", "2d") when [DateUtils] returns null (e.g. under JVM unit tests where
36− * `isReturnDefaultValues = true` stubs the framework call to null). For
37− * weeks/months/years — which [DateUtils] doesn't provide short abbreviations for —
38− * uses the compact numeric form with a locale-stable unit suffix. Always returns a
33+ * The compact single-letter form is deliberate: these labels sit in dense action rows and list
34+ * rows next to other controls, so they must stay narrow. (An earlier version used
35+ * [DateUtils.getRelativeTimeSpanString] with `FORMAT_ABBREV_RELATIVE` for minutes/hours/days,
36+ * but that returns a full phrase like "5 min. ago" / "2 hr. ago" — not the compact form the
37+ * examples and the numeric fallback imply — which wraps or crowds those rows.) Always returns a
3938 * non-empty string for a valid timestamp.
4039 */
4140fun relativeTime(epochMillis: Long?): String {
@@ -45,26 +44,11 @@ fun relativeTime(epochMillis: Long?): String {
4544 if (diff < 0) return "now"
4645 val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
4746 if (minutes < 1) return "now"
48− if (minutes < 60) {
49− val locale = DateUtils.getRelativeTimeSpanString(
50− epochMillis, now, DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
51− )?.toString()
52− return locale?.takeIf { it.isNotBlank() } ?: "${minutes}m"
53− }
47+ if (minutes < 60) return "${minutes}m"
5448 val hours = TimeUnit.MILLISECONDS.toHours(diff)
55− if (hours < 24) {
56− val locale = DateUtils.getRelativeTimeSpanString(
57− epochMillis, now, DateUtils.HOUR_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
58− )?.toString()
59− return locale?.takeIf { it.isNotBlank() } ?: "${hours}h"
60− }
49+ if (hours < 24) return "${hours}h"
6150 val days = TimeUnit.MILLISECONDS.toDays(diff)
62− if (days < 7) {
63− val locale = DateUtils.getRelativeTimeSpanString(
64− epochMillis, now, DateUtils.DAY_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
65− )?.toString()
66− return locale?.takeIf { it.isNotBlank() } ?: "${days}d"
67− }
51+ if (days < 7) return "${days}d"
6852 // DateUtils doesn't offer a short week/month/year abbreviation; use a compact
6953 // numeric form. The suffix is locale-stable (English letter) but these are
7054 // rare timestamps (older than a week) and the numeric value is the primary signal.
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+5−1
@@ -132,7 +132,11 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
132132
133133 /** Cancel a pending delete (Undo snackbar action) and re-show the hidden row. */
134134 fun undoDelete(profileId: String) {
135− container.cancelProfileDelete(profileId)
135+ // If the deferred delete already fired (cancel returns false), the delete is
136+ // committing/committed — don't re-show the row, or we'd briefly resurrect a profile
137+ // that's on its way out (the onDeleted/onError callbacks un-hide it once the store
138+ // settles). Mirrors SessionListViewModel.undoDelete.
139+ if (!container.cancelProfileDelete(profileId)) return
136140 _hiddenIds.update { it - profileId }
137141 }
138142}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+60−43
@@ -22,7 +22,9 @@ import kotlinx.coroutines.CancellationException
2222import kotlinx.coroutines.ExperimentalCoroutinesApi
2323import kotlinx.coroutines.Job
2424import kotlinx.coroutines.channels.BufferOverflow
25+import kotlinx.coroutines.currentCoroutineContext
2526import kotlinx.coroutines.delay
27+import kotlinx.coroutines.isActive
2628import kotlinx.coroutines.flow.MutableSharedFlow
2729import kotlinx.coroutines.flow.MutableStateFlow
2830import kotlinx.coroutines.flow.SharedFlow
@@ -288,56 +290,71 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
288290 // initial load (which may have run before auto-reconnect finished and
289291 // found no connection) doesn't leave a stale "not connected" error.
290292 refresh()
291− try {
292− conn.events.events.collect { event ->
293− when (event) {
294− is SessionUpdated -> {
295− val session = event.properties.info
296− // Ignore updates for a session the user just deleted (still
297− // within its undo window): the server hasn't deleted it yet
298− // and can legitimately emit updates, but buffering them here
299− // would resurrect the optimistically-hidden row mid-undo.
300− if (pendingDeletes.containsKey(session.id)) return@collect
301− pendingSessionUpdates[session.id] = session
302− if (sessionUpdateJob?.isActive != true) {
303− sessionUpdateJob = viewModelScope.launch {
304− delay(NetworkConfig.sessionUpdateDebounceMs)
305− flushPendingSessionUpdates()
293+ // Loop the inner collect so a transient non-cancellation exception doesn't
294+ // terminate the collector for this connection. Without the loop, the catch returns
295+ // the collectLatest lambda normally, so collectLatest considers this connection's
296+ // block complete and never re-runs it — live session-list updates (new/renamed/
297+ // deleted sessions) would go permanently silent until a server switch. (Same
298+ // pattern as ChatViewModel's event collectors.)
299+ while (currentCoroutineContext().isActive) {
300+ try {
301+ conn.events.events.collect { event ->
302+ when (event) {
303+ is SessionUpdated -> {
304+ val session = event.properties.info
305+ // Ignore updates for a session the user just deleted (still
306+ // within its undo window): the server hasn't deleted it yet
307+ // and can legitimately emit updates, but buffering them here
308+ // would resurrect the optimistically-hidden row mid-undo.
309+ if (pendingDeletes.containsKey(session.id)) return@collect
310+ pendingSessionUpdates[session.id] = session
311+ if (sessionUpdateJob?.isActive != true) {
312+ sessionUpdateJob = viewModelScope.launch {
313+ delay(NetworkConfig.sessionUpdateDebounceMs)
314+ flushPendingSessionUpdates()
315+ }
306316 }
307317 }
308− }
309− is SessionDeleted -> {
310− val id = event.properties.info?.id ?: event.properties.sessionID
311− if (id != null) {
312− synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
313− // Drop any buffered update for this id so a pending flush
314− // can't re-add the just-deleted session to the list.
315− pendingSessionUpdates.remove(id)
316− // The session is gone server-side; cancel any pending local
317− // delete so its deferred REST call doesn't later 404 and
318− // trigger the failure-restore path that resurrects the row.
319− pendingDeletes.remove(id)
320− container.draftStore.remove(id)
321− // Also drop this session's other local artifacts so an
322− // externally-deleted session leaves nothing orphaned.
323− container.attachmentDraftStore.remove(id)
324− container.messageCacheStore.remove(id)
325− container.sessionPrefsStore.forget(id)
326− _state.update { s ->
327− s.copy(
328− sessions = s.sessions.filterNot { it.id == id },
329− previews = s.previews - id,
330− )
318+ is SessionDeleted -> {
319+ val id = event.properties.info?.id ?: event.properties.sessionID
320+ if (id != null) {
321+ synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
322+ // Drop any buffered update for this id so a pending flush
323+ // can't re-add the just-deleted session to the list.
324+ pendingSessionUpdates.remove(id)
325+ // The session is gone server-side; cancel the pending local
326+ // delete via the CONTAINER, which owns the deferred timer —
327+ // removing only from our local tracking map (as this once
328+ // did) leaves the timer scheduled to fire a redundant DELETE
329+ // (and, on a non-404 failure at fire time, resurrect the row
330+ // via onError). Mirrors the reconnect sweep above / undoDelete.
331+ container.cancelSessionDelete(id)
332+ pendingDeletes.remove(id)
333+ // Also drop this session's other local artifacts so an
334+ // externally-deleted session leaves nothing orphaned. Guard
335+ // each disk op: an unguarded IOException here would propagate
336+ // out of collect (restarting the loop needlessly, or — before
337+ // the loop existed — killing live updates for good).
338+ runCatchingCancellable { container.draftStore.remove(id) }
339+ runCatchingCancellable { container.attachmentDraftStore.remove(id) }
340+ runCatchingCancellable { container.messageCacheStore.remove(id) }
341+ runCatchingCancellable { container.sessionPrefsStore.forget(id) }
342+ _state.update { s ->
343+ s.copy(
344+ sessions = s.sessions.filterNot { it.id == id },
345+ previews = s.previews - id,
346+ )
347+ }
331348 }
332349 }
350+ else -> {}
333351 }
334− else -> {}
335352 }
353+ } catch (e: CancellationException) {
354+ throw e
355+ } catch (e: Exception) {
356+ Log.w("SessionListVM", "SSE event collector error, restarting", e)
336357 }
337− } catch (e: CancellationException) {
338− throw e
339− } catch (e: Exception) {
340− Log.w("SessionListVM", "SSE event collector error", e)
341358 }
342359 }
343360 }
app/src/main/res/values-es/strings.xml+1
@@ -131,6 +131,7 @@
131131 <string name="stop_run_text">El agente sigue trabajando. ¿Detenerlo ahora?</string>
132132 <string name="session_deleted_msg">Esta sesión se eliminó en otro dispositivo.</string>
133133 <string name="queued">En cola — se enviará cuando termine la ejecución</string>
134+ <string name="queue_followup">Poner seguimiento en cola</string>
134135
135136 <!-- Errors (view models) -->
136137 <string name="error_generic">Algo salió mal.</string>
app/src/main/res/values/strings.xml+1
@@ -131,6 +131,7 @@
131131 <string name="stop_run_text">The agent is still working. Stop it now?</string>
132132 <string name="session_deleted_msg">This session was deleted on another device.</string>
133133 <string name="queued">Queued — will send when the run finishes</string>
134+ <string name="queue_followup">Queue follow-up</string>
134135
135136 <!-- Errors (view models) -->
136137 <string name="error_generic">Something went wrong.</string>