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
807ef77iko
app/src/main/java/soy/iko/opencode/MainActivity.kt+15−3
@@ -211,9 +211,21 @@ class MainActivity : FragmentActivity() {
211
211
212 override fun onSaveInstanceState(outState: Bundle) {
212 override fun onSaveInstanceState(outState: Bundle) {
213 super.onSaveInstanceState(outState)
213 super.onSaveInstanceState(outState)
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()
214− outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
226+ outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
215− outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
227+ outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
216− outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled)
228+ outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
217 outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
229 outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
218 }
230 }
219
231
app/src/main/java/soy/iko/opencode/data/model/Agent.kt+7−2
@@ -7,7 +7,10 @@ import kotlinx.serialization.Serializable
7@Immutable
7@Immutable
8@Serializable
8@Serializable
9data class Agent(
9data class Agent(
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.
10− val name: String,
13+ val name: String = "",
11 val description: String? = null,
14 val description: String? = null,
12 val mode: String? = null,
15 val mode: String? = null,
13 val builtIn: Boolean = false,
16 val builtIn: Boolean = false,
@@ -21,7 +24,9 @@ data class Agent(
21@Immutable
24@Immutable
22@Serializable
25@Serializable
23data class Command(
26data class Command(
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.
24− val name: String,
29+ val name: String = "",
25 val description: String? = null,
30 val description: String? = null,
26 val agent: String? = null,
31 val agent: String? = null,
27 val model: String? = null,
32 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
3import androidx.compose.runtime.Immutable
3import androidx.compose.runtime.Immutable
4import kotlinx.serialization.SerialName
4import kotlinx.serialization.SerialName
5import kotlinx.serialization.Serializable
5import kotlinx.serialization.Serializable
6−import kotlinx.serialization.json.JsonObject
6+import kotlinx.serialization.json.JsonElement
7import kotlinx.serialization.json.JsonClassDiscriminator
7import kotlinx.serialization.json.JsonClassDiscriminator
8
8
9/**
9/**
@@ -39,7 +39,11 @@ data class AssistantMessage(
39 val modelID: String? = null,
39 val modelID: String? = null,
40 val cost: Double? = null,
40 val cost: Double? = null,
41 val tokens: Tokens? = null,
41 val tokens: Tokens? = 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.
42− val error: JsonObject? = null,
46+ val error: JsonElement? = null,
43 override val time: TimeInfo? = null,
47 override val time: TimeInfo? = null,
44) : MessageInfo {
48) : MessageInfo {
45 /** A run is complete once the server stamps a completion time. */
49 /** 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
3import androidx.compose.runtime.Immutable
3import androidx.compose.runtime.Immutable
4import kotlinx.serialization.Serializable
4import kotlinx.serialization.Serializable
5import kotlinx.serialization.json.JsonElement
5import kotlinx.serialization.json.JsonElement
6−import kotlinx.serialization.json.JsonObject
7import kotlinx.serialization.json.JsonArray
6import kotlinx.serialization.json.JsonArray
8import kotlinx.serialization.json.JsonPrimitive
7import kotlinx.serialization.json.JsonPrimitive
9
8
@@ -15,14 +14,16 @@ import kotlinx.serialization.json.JsonPrimitive
15@Immutable
14@Immutable
16@Serializable
15@Serializable
17data class Permission(
16data class Permission(
18− val id: String,
17+ val id: String = "",
19 val sessionID: String = "",
18 val sessionID: String = "",
20 val type: String? = null,
19 val type: String? = null,
21 val pattern: JsonElement? = null,
20 val pattern: JsonElement? = null,
22 val messageID: String? = null,
21 val messageID: String? = null,
23 val callID: String? = null,
22 val callID: String? = null,
24 val title: String? = null,
23 val title: String? = 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.
25− val metadata: JsonObject? = null,
26+ val metadata: JsonElement? = null,
26 val time: TimeInfo? = null,
27 val time: TimeInfo? = null,
27) {
28) {
28 val patternText: String?
29 val patternText: String?
app/src/main/java/soy/iko/opencode/data/model/Provider.kt+4−1
@@ -14,7 +14,10 @@ data class ProvidersResponse(
14@Immutable
14@Immutable
15@Serializable
15@Serializable
16data class Provider(
16data class Provider(
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.
17− val id: String,
20+ val id: String = "",
18 val name: String? = null,
21 val name: String? = null,
19 val models: Map<String, ModelInfo> = emptyMap(),
22 val models: Map<String, ModelInfo> = emptyMap(),
20) {
23) {
app/src/main/java/soy/iko/opencode/data/model/Session.kt+4−1
@@ -7,7 +7,10 @@ import kotlinx.serialization.Serializable
7@Immutable
7@Immutable
8@Serializable
8@Serializable
9data class Session(
9data class Session(
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.
10− val id: String,
13+ val id: String = "",
11 val parentID: String? = null,
14 val parentID: String? = null,
12 val title: String? = null,
15 val title: String? = null,
13 val version: String? = null,
16 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
4import io.ktor.client.HttpClient
4import io.ktor.client.HttpClient
5import io.ktor.client.engine.okhttp.OkHttp
5import io.ktor.client.engine.okhttp.OkHttp
6import io.ktor.client.plugins.HttpTimeout
6import io.ktor.client.plugins.HttpTimeout
7+import io.ktor.client.plugins.api.Send
8+import io.ktor.client.plugins.api.createClientPlugin
7import io.ktor.client.plugins.auth.Auth
9import io.ktor.client.plugins.auth.Auth
8import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
10import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
9import io.ktor.client.plugins.auth.providers.basic
11import io.ktor.client.plugins.auth.providers.basic
10import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
12import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
11import io.ktor.client.plugins.defaultRequest
13import io.ktor.client.plugins.defaultRequest
12import io.ktor.client.plugins.sse.SSE
14import io.ktor.client.plugins.sse.SSE
13−import io.ktor.client.request.header
15+import io.ktor.http.HttpHeaders
14import io.ktor.http.URLBuilder
16import io.ktor.http.URLBuilder
15import io.ktor.http.takeFrom
17import io.ktor.http.takeFrom
16import io.ktor.serialization.kotlinx.json.json
18import io.ktor.serialization.kotlinx.json.json
@@ -101,27 +103,34 @@ object HttpClientFactory {
101 }
103 }
102 }
104 }
103 }
105 }
104− } else {
106+ } else if (pinHost != null) {
105− // For HTTP profiles with auth, send credentials proactively in the
107+ // For HTTP profiles with auth, attach the Basic header proactively (the reactive
106− // Authorization header via defaultRequest (below) so the user's intent
108+ // Auth plugin is skipped for non-HTTPS so a 401 can't silently re-send credentials
107− // to connect over cleartext is honored, but we never install the Auth
109+ // in cleartext), but scope it to the configured host and re-check on EVERY physical
108− // plugin's reactive challenge-response which would silently re-send
110+ // send. Redirects matter: Ktor builds the redirect request by copying the original
109− // credentials on any 401 without checking the protocol.
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+ )
110 }
129 }
111 }
130 }
112
131
113 defaultRequest {
132 defaultRequest {
114 url.takeFrom(URLBuilder().takeFrom(normalizedUrl))
133 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− }
125 }
134 }
126 }
135 }
127 }
136 }
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+8
@@ -40,6 +40,14 @@ object NetworkConfig {
40 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
40 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
41 const val sseEventBufferCapacity = 64
41 const val sseEventBufferCapacity = 64
42
42
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+
43 // --- Catalog cache (OpencodeApiClient) ---
51 // --- Catalog cache (OpencodeApiClient) ---
44
52
45 /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
53 /** 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?) {
93 }
93 }
94 override fun onServiceLost(serviceInfo: NsdServiceInfo) {
94 override fun onServiceLost(serviceInfo: NsdServiceInfo) {
95 val name = serviceInfo.serviceName ?: return
95 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.
96+ // If it was already resolved and in `found`, remove and emit now — and do NOT set a
98− lostPending.add(name)
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) {
99− if (found.remove(name) != null) trySend(found.values.sortedBy { it.name })
103+ trySend(found.values.sortedBy { it.name })
104+ } else {
105+ lostPending.add(name)
106+ }
100 }
107 }
101 override fun onDiscoveryStopped(serviceType: String?) {}
108 override fun onDiscoveryStopped(serviceType: String?) {}
102 override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
109 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(
74
74
75 /** Read the on-disk queue into memory. MUST be called while holding [ioMutex]. Idempotent
75 /** Read the on-disk queue into memory. MUST be called while holding [ioMutex]. Idempotent
76 * (guarded by [loaded]) so a [mutate] that arrives before [load] can call it to guarantee
76 * (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. */
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. */
78− private suspend fun loadLocked() {
80+ private suspend fun loadLocked(): Boolean {
79− if (loaded) return
81+ if (loaded) return true
80 val f = file?.takeIf { it.exists() }
82 val f = file?.takeIf { it.exists() }
81 if (f == null) {
83 if (f == null) {
82 // A genuinely absent file is a normal empty queue: mark loaded so mutations proceed.
84 // A genuinely absent file is a normal empty queue: mark loaded so mutations proceed.
83 loaded = true
85 loaded = true
84− return
86+ return true
85 }
87 }
86 // Only mark [loaded] AFTER a successful read. A transient read/decode failure (or a
88 // Only mark [loaded] AFTER a successful read. A transient read/decode failure (or a
87 // cancelled read) of an *existing* file must not clobber the still-valid on-disk queue:
89 // 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,
90+ // leave loaded=false and report failure so the caller skips its write and a later
89− // instead of persisting an empty snapshot over the user's offline-composed messages.
91+ // mutate/load retries the read, instead of persisting an empty snapshot over the user's
92+ // offline-composed messages.
90 val diskList = withContext(Dispatchers.IO) {
93 val diskList = withContext(Dispatchers.IO) {
91 runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
94 runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
92− }.getOrElse { return }
95+ }.getOrElse { return false }
93 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
96 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
94 // the in-memory version on conflict (it's newer).
97 // the in-memory version on conflict (it's newer).
95 if (_messages.value.isEmpty()) {
98 if (_messages.value.isEmpty()) {
@@ -104,6 +107,7 @@ open class OutboxStore private constructor(
104 persist(merged)
107 persist(merged)
105 }
108 }
106 loaded = true
109 loaded = true
110+ return true
107 }
111 }
108
112
109 open suspend fun enqueue(message: OutboxMessage) = mutate { current ->
113 open suspend fun enqueue(message: OutboxMessage) = mutate { current ->
@@ -127,10 +131,16 @@ open class OutboxStore private constructor(
127 // Ensure the persisted queue has been read into memory before mutating: a mutation
131 // Ensure the persisted queue has been read into memory before mutating: a mutation
128 // that races ahead of load() would otherwise transform an empty in-memory snapshot
132 // that races ahead of load() would otherwise transform an empty in-memory snapshot
129 // and persist()/delete() the file, destroying messages queued in a previous run.
133 // and persist()/delete() the file, destroying messages queued in a previous run.
130− loadLocked()
134+ val loadedOk = loadLocked()
131 val updated = transform(_messages.value).sortedBy { it.createdAt }
135 val updated = transform(_messages.value).sortedBy { it.createdAt }
132 _messages.value = updated
136 _messages.value = 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.
133− persist(updated)
143+ if (loadedOk) persist(updated)
134 }
144 }
135 }
145 }
136
146
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+74−49
@@ -513,7 +513,7 @@ open class AppContainer private constructor(
513 // the shared-event collector here would stall the SharedFlow,
513 // the shared-event collector here would stall the SharedFlow,
514 // overflowing its DROP_OLDEST buffer and silently dropping live
514 // overflowing its DROP_OLDEST buffer and silently dropping live
515 // parts/updates for every other subscriber (e.g. the message reducer).
515 // 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) }
517 }
517 }
518 return@collect
518 return@collect
519 }
519 }
@@ -617,8 +617,11 @@ open class AppContainer private constructor(
617 originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
617 originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
618 }.getOrNull() ?: sessionId
618 }.getOrNull() ?: sessionId
619
619
620− /** 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.
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]). */
621− private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository) {
624+ private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
622 val title = resolveSessionTitle(sessionId, originRepo)
625 val title = resolveSessionTitle(sessionId, originRepo)
623 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
626 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
624 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
627 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
@@ -626,7 +629,7 @@ open class AppContainer private constructor(
626 // prior notification — posting a fresh one here would leave a "session ready" heads-up
629 // prior notification — posting a fresh one here would leave a "session ready" heads-up
627 // lingering while they're already looking at the finished run.
630 // lingering while they're already looking at the finished run.
628 if (isActivelyViewing(sessionId)) return
631 if (isActivelyViewing(sessionId)) return
629− appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
632+ appContext?.let { SessionNotifications.postCompleted(it, sessionId, title, originProfileId) }
630 }
633 }
631
634
632 /** Post a heads-up permission notification for [permission], resolving its session title.
635 /** Post a heads-up permission notification for [permission], resolving its session title.
@@ -701,15 +704,19 @@ open class AppContainer private constructor(
701 * immediate send when a connection is live, so an online reply still goes out now. [onDone]
704 * immediate send when a connection is live, so an online reply still goes out now. [onDone]
702 * reports whether the reply was durably enqueued so the receiver only dismisses the
705 * reports whether the reply was durably enqueued so the receiver only dismisses the
703 * notification on success. */
706 * 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) {
705 appScope.launch {
708 appScope.launch {
706 // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
709 // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
707 // scope is cancelled mid-call — see respondToPermissionFromNotification above.
710 // scope is cancelled mid-call — see respondToPermissionFromNotification above.
708 var enqueued = false
711 var enqueued = false
709 try {
712 try {
710− // Attribute to the active (or most-recent) profile so a reconnect to a different
713+ // Attribute to the profile that POSTED the notification (embedded in the reply
711− // server doesn't misfire this against a session id that only exists elsewhere.
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
712− val profileId = activeConnection.value?.profile?.id
719+ ?: activeConnection.value?.profile?.id
713 ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
720 ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
714 enqueued = if (profileId == null) {
721 enqueued = if (profileId == null) {
715 false
722 false
@@ -748,28 +755,24 @@ open class AppContainer private constructor(
748 .getOrDefault(emptyList())
755 .getOrDefault(emptyList())
749 .firstOrNull() ?: return
756 .firstOrNull() ?: return
750 // The profile read above suspends on DataStore. A manual connect() to a different
757 // 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
758+ // server during that window establishes a connection the user explicitly chose; we must
752− // must not clobber it by connecting to `recent` on top. connect() unconditionally
759+ // not clobber it by connecting to `recent` on top. connectIfIdle() checks "is anything
753− // closes the active connection, so guard here: if anything is already active, the
760+ // active?" and connects atomically under connectionMutex, returning null if the cold-start
754− // user (or another connector) already took the cold-start slot — abort autoConnect.
761+ // slot was already taken — closing the TOCTOU window an unlocked null-check plus connect()'s
755− // (disconnectIf below similarly guards the failed-ping teardown by identity.)
762+ // unconditional close would leave. (disconnectIf below similarly guards the failed-ping
756− if (_activeConnection.value != null) return
763+ // teardown by identity.)
757 _reconnecting.value = true
764 _reconnecting.value = true
758 var conn: OpencodeConnection? = null
765 var conn: OpencodeConnection? = null
759 val ok = runCatchingCancellable {
766 val ok = runCatchingCancellable {
760− conn = connect(recent)
767+ conn = connectIfIdle(recent) ?: return@runCatchingCancellable false
761 conn!!.api.ping()
768 conn!!.api.ping()
762− }.isSuccess
769+ true
770+ }.getOrDefault(false)
763 _reconnecting.value = false
771 _reconnecting.value = false
764− if (ok) {
772+ when {
773+ conn == null -> Unit // slot already taken by a manual connect — leave it be
765− _autoConnectDone.value = true
774+ ok -> _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)
775+ else -> disconnectIf(conn!!)
773 }
776 }
774 }
777 }
775
778
@@ -788,12 +791,21 @@ open class AppContainer private constructor(
788 _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
791 _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
789 val callback = object : ConnectivityManager.NetworkCallback() {
792 val callback = object : ConnectivityManager.NetworkCallback() {
790 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
793 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
794+ // A matched (internet-capable) network was lost. Don't eagerly flip offline: during a
791− // Don't flip offline on onLost: during a Wi-Fi→cellular handoff, onLost(WiFi) fires
795+ // Wi-Fi→cellular handoff onLost(WiFi) fires before onAvailable(cellular) and
792− // before onAvailable(cellular), and cm.activeNetwork is transiently null in that
796+ // cm.activeNetwork is transiently null in that window — flashing offline there would
793− // window — eagerly marking offline would skip outbox flushing and flash an offline
797+ // skip outbox flushing and blink the indicator. Instead re-check activeNetwork after a
794− // indicator. onUnavailable (no networks left at all) is the reliable offline signal.
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.
795− override fun onLost(network: Network) { /* handled by onUnavailable */ }
803+ override fun onLost(network: Network) {
796− override fun onUnavailable() { _isOnline.value = false }
804+ appScope.launch {
805+ delay(NetworkConfig.networkOfflineGraceMs)
806+ _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
807+ }
808+ }
797 }
809 }
798 // Only match networks that actually provide internet — a capability-less request
810 // Only match networks that actually provide internet — a capability-less request
799 // fires onAvailable for transports that can't reach the server (and would trigger
811 // fires onAvailable for transports that can't reach the server (and would trigger
@@ -810,26 +822,39 @@ open class AppContainer private constructor(
810 * Only persists the updated `lastUsed` timestamp if it's stale (older than a
822 * Only persists the updated `lastUsed` timestamp if it's stale (older than a
811 * minute), so rapid reconnect storms don't each trigger a DataStore write. */
823 * minute), so rapid reconnect storms don't each trigger a DataStore write. */
812 open suspend fun connect(profile: ServerProfile): OpencodeConnection =
824 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? =
813 connectionMutex.withLock {
832 connectionMutex.withLock {
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 {
814− _activeConnection.value?.close()
839+ _activeConnection.value?.close()
815− _activeConnection.value = null
840+ _activeConnection.value = null
816− // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
841+ // 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
842+ // 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-
843+ // 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.
844+ // down connection and no SessionIdle to clear it — a stuck foreground service.
820− synchronized(activeRuns) {
845+ synchronized(activeRuns) {
821− activeRuns.clear()
846+ activeRuns.clear()
822− _anyRunActive.value = false
847+ _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 }
832 }
848 }
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+ }
833
858
834 open suspend fun disconnect() {
859 open suspend fun disconnect() {
835 // Acquire the connection mutex so disconnect is serialized with connect().
860 // 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() {
55 ACTION_REPLY -> {
55 ACTION_REPLY -> {
56 val text = RemoteInput.getResultsFromIntent(intent)
56 val text = RemoteInput.getResultsFromIntent(intent)
57 ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
57 ?.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() }
59 val pending = goAsync()
67 val pending = goAsync()
60 val finished = AtomicBoolean(false)
68 val finished = AtomicBoolean(false)
61 val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
69 val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
62 watchdog(finishOnce)
70 watchdog(finishOnce)
63− container.sendPromptFromNotification(sessionId, text) { enqueued ->
71+ container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
64 // The reply is durably queued (and flushed now if online); clear the
72 // The reply is durably queued (and flushed now if online); clear the
65 // "session ready" notification. If the enqueue failed, leave it up so the
73 // "session ready" notification. If the enqueue failed, leave it up so the
66 // reply isn't silently lost and the user can retry.
74 // 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 {
92 // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
92 // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
93 // check through the helper, so suppress its MissingPermission flag on these posts.
93 // check through the helper, so suppress its MissingPermission flag on these posts.
94 @SuppressLint("MissingPermission")
94 @SuppressLint("MissingPermission")
95− fun postCompleted(context: Context, sessionId: String, title: String) {
95+ fun postCompleted(context: Context, sessionId: String, title: String, profileId: String?) {
96 if (!canPost(context)) return
96 if (!canPost(context)) return
97 val notifId = notifId(NS_COMPLETED, sessionId)
97 val notifId = notifId(NS_COMPLETED, sessionId)
98
98
99 // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
99 // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
100 // NotificationActionReceiver. The PendingIntent MUST be mutable so the system can
100 // 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).
102 val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
104 val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
103 .setLabel(context.getString(R.string.notif_reply_hint))
105 .setLabel(context.getString(R.string.notif_reply_hint))
104 .build()
106 .build()
105 val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
107 val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
106 action = NotificationActionReceiver.ACTION_REPLY
108 action = NotificationActionReceiver.ACTION_REPLY
107 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
109 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
110+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
108 }
111 }
109 val replyPending = PendingIntent.getBroadcast(
112 val replyPending = PendingIntent.getBroadcast(
110 context, notifId, replyIntent,
113 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) {
118 // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
118 // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
119 // BiometricPrompts, which throws.
119 // BiometricPrompts, which throws.
120 var authInFlight by remember { mutableStateOf(false) }
120 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) }
121
127
122 fun authenticate() {
128 fun authenticate() {
123 if (authInFlight || unlocked) return
129 if (authInFlight || unlocked) return
124 authInFlight = true
130 authInFlight = true
131+ val myPrompt = ++promptId[0]
125 val executor = ContextCompat.getMainExecutor(activity)
132 val executor = ContextCompat.getMainExecutor(activity)
126 val callback = object : BiometricPrompt.AuthenticationCallback() {
133 val callback = object : BiometricPrompt.AuthenticationCallback() {
127 override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
134 override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
135+ if (myPrompt != promptId[0]) return
128 authInFlight = false
136 authInFlight = false
129 unlocked = true
137 unlocked = true
130 }
138 }
131
139
132 override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
140 override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
133 // User cancelled / lockout / hardware error: stay locked and let them retry
141 // 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
135 authInFlight = false
145 authInFlight = false
136 }
146 }
137
147
@@ -145,7 +155,7 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
145 .setAllowedAuthenticators(lockAuthenticators)
155 .setAllowedAuthenticators(lockAuthenticators)
146 .build()
156 .build()
147 runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
157 runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
148− .onFailure { authInFlight = false }
158+ .onFailure { if (myPrompt == promptId[0]) authInFlight = false }
149 }
159 }
150
160
151 // Re-lock when the app is backgrounded, and re-prompt when it returns to the foreground.
161 // 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(
365 }
365 }
366
366
367 // Navigate to a session freshly branched from a message (branchFrom), once it's created.
367 // 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.
368 val branchedMsg = stringResource(R.string.branch_created)
371 val branchedMsg = stringResource(R.string.branch_created)
369− LaunchedEffect(onOpenSession) {
372+ LaunchedEffect(Unit) {
370 vm.branchEvents.collect { newId ->
373 vm.branchEvents.collect { newId ->
371 showToast(shareContext, branchedMsg)
374 showToast(shareContext, branchedMsg)
372 onOpenSession?.invoke(newId)
375 onOpenSession?.invoke(newId)
@@ -1232,6 +1235,7 @@ private fun ChatInputBar(
1232 canSend = enabled && hasContent,
1235 canSend = enabled && hasContent,
1233 onSend = onSend,
1236 onSend = onSend,
1234 onAbort = onAbort,
1237 onAbort = onAbort,
1238+ onQueue = { onQueueFollowUp(value) },
1235 )
1239 )
1236 }
1240 }
1237 }
1241 }
@@ -1252,8 +1256,9 @@ private fun enterShouldSend(
1252 else -> ctrl
1256 else -> ctrl
1253}
1257}
1254
1258
1255−/** The composer's trailing button: a Stop (with in-flight spinner) while a run is active,
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
1256− * otherwise Send. Extracted so [ChatInputBar] stays under the complexity threshold. */
1261+ * [ChatInputBar] stays under the complexity threshold. */
1257@Composable
1262@Composable
1258private fun ComposerTrailingButton(
1263private fun ComposerTrailingButton(
1259 running: Boolean,
1264 running: Boolean,
@@ -1261,23 +1266,43 @@ private fun ComposerTrailingButton(
1261 canSend: Boolean,
1266 canSend: Boolean,
1262 onSend: () -> Unit,
1267 onSend: () -> Unit,
1263 onAbort: () -> Unit,
1268 onAbort: () -> Unit,
1269+ onQueue: () -> Unit,
1264) {
1270) {
1265 if (running) {
1271 if (running) {
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+ }
1266− // Show a spinner while the abort REST call is in flight so the user sees the stop
1290+ // 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.
1291+ // was sent, and disable to prevent a double-tap.
1268− IconButton(
1292+ IconButton(
1269− onClick = onAbort,
1293+ onClick = onAbort,
1270− enabled = !aborting,
1294+ enabled = !aborting,
1271− modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
1295+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
1272− ) {
1296+ ) {
1273− if (aborting) {
1297+ if (aborting) {
1274− val stopLabel = stringResource(R.string.stop)
1298+ val stopLabel = stringResource(R.string.stop)
1275− CircularProgressIndicator(
1299+ CircularProgressIndicator(
1276− Modifier.size(18.dp).semantics { contentDescription = stopLabel },
1300+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
1277− strokeWidth = 2.dp,
1301+ strokeWidth = 2.dp,
1278− )
1302+ )
1279− } else {
1303+ } else {
1280− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
1304+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
1305+ }
1281 }
1306 }
1282 }
1307 }
1283 } else {
1308 } else {
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+19−4
@@ -25,6 +25,7 @@ import androidx.compose.material3.MaterialTheme
25import androidx.compose.material3.Text
25import androidx.compose.material3.Text
26import androidx.compose.runtime.Composable
26import androidx.compose.runtime.Composable
27import androidx.compose.runtime.getValue
27import androidx.compose.runtime.getValue
28+import androidx.compose.runtime.key
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.remember
30import androidx.compose.runtime.remember
30import androidx.compose.runtime.setValue
31import androidx.compose.runtime.setValue
@@ -168,8 +169,14 @@ private fun UnknownMessageBlock(
168 modifier = Modifier.padding(start = 6.dp),
169 modifier = Modifier.padding(start = 6.dp),
169 )
170 )
170 }
171 }
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.)
171− for (part in message.parts) {
176+ message.parts.forEachIndexed { index, part ->
177+ key(part.id, index) {
172− PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
178+ PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
179+ }
173 }
180 }
174 MessageTimestampText(message.info)
181 MessageTimestampText(message.info)
175 }
182 }
@@ -206,7 +213,10 @@ private fun UserBubble(
206 .padding(12.dp),
213 .padding(12.dp),
207 verticalArrangement = Arrangement.spacedBy(6.dp),
214 verticalArrangement = Arrangement.spacedBy(6.dp),
208 ) {
215 ) {
216+ // key() per part so each PartView gets its own saveable registry slot (see AssistantBlock).
217+ message.parts.forEachIndexed { index, part ->
209− for (part in message.parts) PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
218+ key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
219+ }
210 Row(
220 Row(
211 modifier = Modifier.fillMaxWidth(),
221 modifier = Modifier.fillMaxWidth(),
212 horizontalArrangement = Arrangement.SpaceBetween,
222 horizontalArrangement = Arrangement.SpaceBetween,
@@ -288,7 +298,12 @@ private fun AssistantBlock(
288 // isRunning to all of them would keep a completed reasoning block showing the
298 // isRunning to all of them would keep a completed reasoning block showing the
289 // "Thinking…" spinner until the whole message finishes.
299 // "Thinking…" spinner until the whole message finishes.
290 val partStreaming = isRunning && index == message.parts.lastIndex
300 val partStreaming = isRunning && index == message.parts.lastIndex
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) {
291− PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
305+ PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
306+ }
292 }
307 }
293 if (info is AssistantMessage) {
308 if (info is AssistantMessage) {
294 val cost = info.cost
309 val cost = info.cost
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+17−16
@@ -116,25 +116,26 @@ private fun MarkdownBody(
116 if (streaming) {
116 if (streaming) {
117 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
117 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
118 val markdownState = rememberUpdatedState(markdown)
118 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) }
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.
123− // conflate() coalesces a burst of token updates into a single emission so the
126+ // conflate() coalesces a burst of token updates into a single emission so the collector
124− // collector sees only the latest value after the previous delay completes. Plain
127+ // sees only the latest value after the previous delay completes. Plain collect (not
125− // collect (not collectLatest) is critical: collectLatest would cancel the delay
128+ // collectLatest) is critical: collectLatest would cancel the delay on every new value,
126− // on every new value, reintroducing the same starvation the keyed effect had.
129+ // reintroducing the same starvation a keyed effect has.
127− LaunchedEffect(prefix) {
130+ LaunchedEffect(Unit) {
128 snapshotFlow { markdownState.value }
131 snapshotFlow { markdownState.value }
129 .conflate()
132 .conflate()
130 .collect { md ->
133 .collect { md ->
131− // Only throttle when the content is growing incrementally (streaming):
134+ // Only throttle when the content is growing incrementally (streaming): a
132− // a content switch or initial render proceeds immediately. Checking
135+ // shorter switch or initial render proceeds immediately. Checking length is
133− // length is O(1) vs. startsWith which is O(n) in the rendered content
136+ // O(1) vs. startsWith which is O(n) in the rendered content length — during a
134− // length — during a long streaming response this runs every throttle
137+ // long streaming response this runs every throttle cycle. Within one streaming
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.
138+ // composition, growing length means appending (streaming), not a rewrite.
138 if (renderedContent.isNotEmpty() &&
139 if (renderedContent.isNotEmpty() &&
139 md.length > renderedContent.length
140 md.length > renderedContent.length
140 ) {
141 ) {
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 {
150@Composable
150@Composable
151fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
151fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
152 val context = LocalContext.current
152 val context = LocalContext.current
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).
153− val model = produceState<Any?>(initialValue = null, part.source, part.url, ctx.baseUrl) {
159+ val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl) {
154 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
160 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+ }
156 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model) {
167 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model) {
157 ImageRequest.Builder(context)
168 ImageRequest.Builder(context)
158 .data(model)
169 .data(model)
@@ -183,17 +194,29 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
183 )
194 )
184 }
195 }
185 },
196 },
186− error = {
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) {
187− Box(
209+ Box(
188− modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
210+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
189− contentAlignment = Alignment.Center,
211+ contentAlignment = Alignment.Center,
190− ) {
212+ ) { content() }
213+}
214+
215+@Composable
216+private fun BrokenImageIcon() {
191− Icon(
217+ Icon(
192− Icons.Filled.BrokenImage,
218+ Icons.Filled.BrokenImage,
193− contentDescription = stringResource(R.string.image_failed),
219+ contentDescription = stringResource(R.string.image_failed),
194− tint = MaterialTheme.colorScheme.onSurfaceVariant,
220+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
195− )
196− }
197− },
198 )
221 )
199}
222}
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+10−26
@@ -27,15 +27,14 @@ import kotlinx.coroutines.delay
27import java.util.concurrent.TimeUnit
27import java.util.concurrent.TimeUnit
28
28
29/**
29/**
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").
31 *
32 *
32− * For coarse units (minutes/hours/days) prefers Android's locale-aware
33+ * The compact single-letter form is deliberate: these labels sit in dense action rows and list
33− * [DateUtils.getRelativeTimeSpanString] so the abbreviations match the device locale
34+ * rows next to other controls, so they must stay narrow. (An earlier version used
34− * instead of hardcoded English. Falls back to a compact numeric form (e.g. "5m",
35+ * [DateUtils.getRelativeTimeSpanString] with `FORMAT_ABBREV_RELATIVE` for minutes/hours/days,
35− * "3h", "2d") when [DateUtils] returns null (e.g. under JVM unit tests where
36+ * but that returns a full phrase like "5 min. ago" / "2 hr. ago" — not the compact form the
36− * `isReturnDefaultValues = true` stubs the framework call to null). For
37+ * examples and the numeric fallback imply — which wraps or crowds those rows.) Always returns a
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
39 * non-empty string for a valid timestamp.
38 * non-empty string for a valid timestamp.
40 */
39 */
41fun relativeTime(epochMillis: Long?): String {
40fun relativeTime(epochMillis: Long?): String {
@@ -45,26 +44,11 @@ fun relativeTime(epochMillis: Long?): String {
45 if (diff < 0) return "now"
44 if (diff < 0) return "now"
46 val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
45 val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
47 if (minutes < 1) return "now"
46 if (minutes < 1) return "now"
48− if (minutes < 60) {
47+ if (minutes < 60) return "${minutes}m"
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− }
54 val hours = TimeUnit.MILLISECONDS.toHours(diff)
48 val hours = TimeUnit.MILLISECONDS.toHours(diff)
55− if (hours < 24) {
49+ if (hours < 24) return "${hours}h"
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− }
61 val days = TimeUnit.MILLISECONDS.toDays(diff)
50 val days = TimeUnit.MILLISECONDS.toDays(diff)
62− if (days < 7) {
51+ if (days < 7) return "${days}d"
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− }
68 // DateUtils doesn't offer a short week/month/year abbreviation; use a compact
52 // DateUtils doesn't offer a short week/month/year abbreviation; use a compact
69 // numeric form. The suffix is locale-stable (English letter) but these are
53 // numeric form. The suffix is locale-stable (English letter) but these are
70 // rare timestamps (older than a week) and the numeric value is the primary signal.
54 // 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() {
132
132
133 /** Cancel a pending delete (Undo snackbar action) and re-show the hidden row. */
133 /** Cancel a pending delete (Undo snackbar action) and re-show the hidden row. */
134 fun undoDelete(profileId: String) {
134 fun undoDelete(profileId: String) {
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.
135− container.cancelProfileDelete(profileId)
139+ if (!container.cancelProfileDelete(profileId)) return
136 _hiddenIds.update { it - profileId }
140 _hiddenIds.update { it - profileId }
137 }
141 }
138}
142}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+60−43
@@ -22,7 +22,9 @@ import kotlinx.coroutines.CancellationException
22import kotlinx.coroutines.ExperimentalCoroutinesApi
22import kotlinx.coroutines.ExperimentalCoroutinesApi
23import kotlinx.coroutines.Job
23import kotlinx.coroutines.Job
24import kotlinx.coroutines.channels.BufferOverflow
24import kotlinx.coroutines.channels.BufferOverflow
25+import kotlinx.coroutines.currentCoroutineContext
25import kotlinx.coroutines.delay
26import kotlinx.coroutines.delay
27+import kotlinx.coroutines.isActive
26import kotlinx.coroutines.flow.MutableSharedFlow
28import kotlinx.coroutines.flow.MutableSharedFlow
27import kotlinx.coroutines.flow.MutableStateFlow
29import kotlinx.coroutines.flow.MutableStateFlow
28import kotlinx.coroutines.flow.SharedFlow
30import kotlinx.coroutines.flow.SharedFlow
@@ -288,56 +290,71 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
288 // initial load (which may have run before auto-reconnect finished and
290 // initial load (which may have run before auto-reconnect finished and
289 // found no connection) doesn't leave a stale "not connected" error.
291 // found no connection) doesn't leave a stale "not connected" error.
290 refresh()
292 refresh()
291− try {
293+ // Loop the inner collect so a transient non-cancellation exception doesn't
292− conn.events.events.collect { event ->
294+ // terminate the collector for this connection. Without the loop, the catch returns
293− when (event) {
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) {
294− is SessionUpdated -> {
303+ is SessionUpdated -> {
295− val session = event.properties.info
304+ val session = event.properties.info
296− // Ignore updates for a session the user just deleted (still
305+ // Ignore updates for a session the user just deleted (still
297− // within its undo window): the server hasn't deleted it yet
306+ // within its undo window): the server hasn't deleted it yet
298− // and can legitimately emit updates, but buffering them here
307+ // and can legitimately emit updates, but buffering them here
299− // would resurrect the optimistically-hidden row mid-undo.
308+ // would resurrect the optimistically-hidden row mid-undo.
300− if (pendingDeletes.containsKey(session.id)) return@collect
309+ if (pendingDeletes.containsKey(session.id)) return@collect
301− pendingSessionUpdates[session.id] = session
310+ pendingSessionUpdates[session.id] = session
302− if (sessionUpdateJob?.isActive != true) {
311+ if (sessionUpdateJob?.isActive != true) {
303− sessionUpdateJob = viewModelScope.launch {
312+ sessionUpdateJob = viewModelScope.launch {
304− delay(NetworkConfig.sessionUpdateDebounceMs)
313+ delay(NetworkConfig.sessionUpdateDebounceMs)
305− flushPendingSessionUpdates()
314+ flushPendingSessionUpdates()
315+ }
306 }
316 }
307 }
317 }
308− }
309− is SessionDeleted -> {
318+ is SessionDeleted -> {
310− val id = event.properties.info?.id ?: event.properties.sessionID
319+ val id = event.properties.info?.id ?: event.properties.sessionID
311− if (id != null) {
320+ if (id != null) {
312− synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
321+ synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
313− // Drop any buffered update for this id so a pending flush
322+ // Drop any buffered update for this id so a pending flush
314− // can't re-add the just-deleted session to the list.
323+ // can't re-add the just-deleted session to the list.
315− pendingSessionUpdates.remove(id)
324+ pendingSessionUpdates.remove(id)
316− // The session is gone server-side; cancel any pending local
325+ // The session is gone server-side; cancel the pending local
317− // delete so its deferred REST call doesn't later 404 and
326+ // delete via the CONTAINER, which owns the deferred timer —
318− // trigger the failure-restore path that resurrects the row.
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)
319− pendingDeletes.remove(id)
332+ pendingDeletes.remove(id)
320− container.draftStore.remove(id)
321− // Also drop this session's other local artifacts so an
333+ // Also drop this session's other local artifacts so an
322− // externally-deleted session leaves nothing orphaned.
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) }
323− container.attachmentDraftStore.remove(id)
339+ runCatchingCancellable { container.attachmentDraftStore.remove(id) }
324− container.messageCacheStore.remove(id)
340+ runCatchingCancellable { container.messageCacheStore.remove(id) }
325− container.sessionPrefsStore.forget(id)
341+ runCatchingCancellable { container.sessionPrefsStore.forget(id) }
326− _state.update { s ->
342+ _state.update { s ->
327− s.copy(
343+ s.copy(
328− sessions = s.sessions.filterNot { it.id == id },
344+ sessions = s.sessions.filterNot { it.id == id },
329− previews = s.previews - id,
345+ previews = s.previews - id,
330− )
346+ )
347+ }
331 }
348 }
332 }
349 }
350+ else -> {}
333 }
351 }
334− else -> {}
335 }
352 }
353+ } catch (e: CancellationException) {
354+ throw e
355+ } catch (e: Exception) {
356+ Log.w("SessionListVM", "SSE event collector error, restarting", e)
336 }
357 }
337− } catch (e: CancellationException) {
338− throw e
339− } catch (e: Exception) {
340− Log.w("SessionListVM", "SSE event collector error", e)
341 }
358 }
342 }
359 }
343 }
360 }
app/src/main/res/values-es/strings.xml+1
@@ -131,6 +131,7 @@
131 <string name="stop_run_text">El agente sigue trabajando. ¿Detenerlo ahora?</string>
131 <string name="stop_run_text">El agente sigue trabajando. ¿Detenerlo ahora?</string>
132 <string name="session_deleted_msg">Esta sesión se eliminó en otro dispositivo.</string>
132 <string name="session_deleted_msg">Esta sesión se eliminó en otro dispositivo.</string>
133 <string name="queued">En cola — se enviará cuando termine la ejecución</string>
133 <string name="queued">En cola — se enviará cuando termine la ejecución</string>
134+ <string name="queue_followup">Poner seguimiento en cola</string>
134
135
135 <!-- Errors (view models) -->
136 <!-- Errors (view models) -->
136 <string name="error_generic">Algo salió mal.</string>
137 <string name="error_generic">Algo salió mal.</string>
app/src/main/res/values/strings.xml+1
@@ -131,6 +131,7 @@
131 <string name="stop_run_text">The agent is still working. Stop it now?</string>
131 <string name="stop_run_text">The agent is still working. Stop it now?</string>
132 <string name="session_deleted_msg">This session was deleted on another device.</string>
132 <string name="session_deleted_msg">This session was deleted on another device.</string>
133 <string name="queued">Queued — will send when the run finishes</string>
133 <string name="queued">Queued — will send when the run finishes</string>
134+ <string name="queue_followup">Queue follow-up</string>
134
135
135 <!-- Errors (view models) -->
136 <!-- Errors (view models) -->
136 <string name="error_generic">Something went wrong.</string>
137 <string name="error_generic">Something went wrong.</string>