git.iko.soy/opencode-android
on master help sign in
Fix REST retry idempotency (createSession key, 404/409 swallowing), move undo-deferred profile/session deletes to a process-lived scope, prevent draft resurrection across async prefs load, and resolve assorted UI/state races (stale message-seed clobbering on reconnect, run-state pinning on disconnect, two-pane deep-link navigation, error-event Channel, file-view find/diff gating, code comment highlighting, relative-time year boundary, connection-banner exit fade).
8bc2cceiko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+15−3
@@ -68,10 +68,22 @@ open class EventStreamClient(
68 /** Conflated channel used to cut the reconnect backoff short when network returns. */
68 /** Conflated channel used to cut the reconnect backoff short when network returns. */
69 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
69 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
70
70
71− /** Returns true if the exception is an SSE auth failure (401/403) that should stop retrying. */
71+ /** Returns true if the exception is an SSE auth failure (401/403) that should stop retrying.
72+ * ktor's SSE builder wraps establishment failures in [io.ktor.client.plugins.sse.SSEClientException]
73+ * (an IllegalStateException, NOT a ClientRequestException), attaching the response and keeping any
74+ * underlying ClientRequestException only as the cause — so we inspect the wrapper's response status
75+ * and walk the whole cause chain rather than matching a single exception type. */
72 private fun isSseAuthFailure(e: Throwable): Boolean =
76 private fun isSseAuthFailure(e: Throwable): Boolean =
77+ generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
78+ .take(16)
79+ .mapNotNull { t ->
80+ when (t) {
81+ is io.ktor.client.plugins.sse.SSEClientException -> t.response?.status?.value
73− e is io.ktor.client.plugins.ClientRequestException &&
82+ is io.ktor.client.plugins.ClientRequestException -> t.response.status.value
74− (e.response.status.value == 401 || e.response.status.value == 403)
83+ else -> null
84+ }
85+ }
86+ .any { it == 401 || it == 403 }
75
87
76 /** Request an immediate reconnect, skipping any in-progress backoff. */
88 /** Request an immediate reconnect, skipping any in-progress backoff. */
77 open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
89 open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+34−11
@@ -71,11 +71,18 @@ open class OpencodeApiClient private constructor(
71 client!!.get("session").body()
71 client!!.get("session").body()
72 }
72 }
73
73
74− open suspend fun createSession(title: String? = null): Session = withRetry {
74+ open suspend fun createSession(title: String? = null): Session {
75+ // Generate the idempotency key before entering withRetry so every retry attempt
76+ // shares it. Without this, a POST that reached the server but whose response was
77+ // lost (reset/timeout) would be retried and create a second orphan session.
78+ val idempotencyKey = java.util.UUID.randomUUID().toString()
79+ return withRetry {
75− client!!.post("session") {
80+ client!!.post("session") {
76− contentType(ContentType.Application.Json)
81+ contentType(ContentType.Application.Json)
82+ header("Idempotency-Key", idempotencyKey)
77− setBody(CreateSessionRequest(title = title))
83+ setBody(CreateSessionRequest(title = title))
78− }.body()
84+ }.body()
85+ }
79 }
86 }
80
87
81 open suspend fun updateSession(id: String, title: String): Session = withRetry {
88 open suspend fun updateSession(id: String, title: String): Session = withRetry {
@@ -86,7 +93,14 @@ open class OpencodeApiClient private constructor(
86 }
93 }
87
94
88 open suspend fun deleteSession(id: String) {
95 open suspend fun deleteSession(id: String) {
96+ try {
89− withRetry { client!!.delete("session/${encode(id)}").body<String>() }
97+ withRetry { client!!.delete("session/${encode(id)}").body<String>() }
98+ } catch (t: ClientRequestException) {
99+ // A DELETE that succeeded but whose response was lost gets retried; the retry
100+ // hits an already-gone session and returns 404. Deleting a non-existent session
101+ // is effectively success, so swallow 404 rather than surfacing a spurious failure.
102+ if (t.response.status.value != 404) throw t
103+ }
90 }
104 }
91
105
92 open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
106 open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
@@ -200,11 +214,20 @@ open class OpencodeApiClient private constructor(
200 sessionId: String,
214 sessionId: String,
201 permissionId: String,
215 permissionId: String,
202 response: PermissionResponse,
216 response: PermissionResponse,
217+ ): String = try {
203− ) = withRetry {
218+ withRetry {
204− client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
219+ client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
205− contentType(ContentType.Application.Json)
220+ contentType(ContentType.Application.Json)
206− setBody(PermissionReplyBody(response.wire))
221+ setBody(PermissionReplyBody(response.wire))
207− }.body<String>()
222+ }.body<String>()
223+ }
224+ } catch (t: ClientRequestException) {
225+ // A response that reached the server but whose reply was lost gets retried; the
226+ // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
227+ // the answer landed, so treat them as success instead of a spurious failure.
228+ val code = t.response.status.value
229+ if (code != 404 && code != 409) throw t
230+ ""
208 }
231 }
209
232
210 // --- Files ---
233 // --- Files ---
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+19−5
@@ -60,8 +60,14 @@ class CrashLogger private constructor(private val appContext: Context) {
60 scope.launch { runCatchingCancellable { refresh() } }
60 scope.launch { runCatchingCancellable { refresh() } }
61 }
61 }
62
62
63+ /** Monotonic guard so a slower earlier scan can't overwrite a newer one's result.
64+ * refresh() runs on the multi-threaded IO dispatcher from several call sites
65+ * (install/deleteReport/scheduleDelete/clearAll), so two scans can overlap. */
66+ private val refreshGeneration = java.util.concurrent.atomic.AtomicLong(0)
67+
63 fun refresh() {
68 fun refresh() {
69+ val generation = refreshGeneration.incrementAndGet()
64− _reports.value = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
70+ val scanned = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
65 ?.sortedByDescending { it.lastModified() }
71 ?.sortedByDescending { it.lastModified() }
66 ?.map { f ->
72 ?.map { f ->
67 CrashReport(
73 CrashReport(
@@ -71,6 +77,9 @@ class CrashLogger private constructor(private val appContext: Context) {
71 )
77 )
72 }
78 }
73 .orEmpty()
79 .orEmpty()
80+ // Publish only if no newer refresh has started meanwhile, so a stale scan (e.g. one
81+ // that started before a delete) can't momentarily re-show a just-removed report.
82+ if (generation == refreshGeneration.get()) _reports.value = scanned
74 }
83 }
75
84
76 fun readReport(fileName: String): String? {
85 fun readReport(fileName: String): String? {
@@ -102,14 +111,19 @@ class CrashLogger private constructor(private val appContext: Context) {
102 fun scheduleDelete(fileName: String, delayMs: Long) {
111 fun scheduleDelete(fileName: String, delayMs: Long) {
103 val job = scope.launch {
112 val job = scope.launch {
104 delay(delayMs)
113 delay(delayMs)
114+ // Atomically claim the delete by removing our own map entry BEFORE deleting.
115+ // The remove is the synchronization point with cancelScheduledDelete: whichever
116+ // removes the entry first wins. If an undo raced the timer and removed it first,
117+ // this returns false and we skip the delete — so the file survives, matching the
118+ // `true` that cancel returned to the UI. Deleting first (then removing) would let
119+ // a cancel land in the gap and falsely report "undo succeeded" for a gone file.
120+ // remove(name, thisJob) also no-ops for a stale job after a reschedule.
121+ val claimed = coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) } == true
122+ if (!claimed) return@launch
105 val file = File(crashDir, fileName).canonicalFile
123 val file = File(crashDir, fileName).canonicalFile
106 if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
124 if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
107 file.delete()
125 file.delete()
108 }
126 }
109− // Remove only if this job is still the registered one. A reschedule for the
110− // same name replaces the map entry with a new job; an unconditional remove
111− // here would evict that newer job's entry, leaking it from cancellation.
112− coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) }
113 refresh()
127 refresh()
114 }
128 }
115 pendingDeletes.put(fileName, job)?.cancel()
129 pendingDeletes.put(fileName, job)?.cancel()
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+41−4
@@ -64,6 +64,24 @@ open class DraftStore private constructor(
64 /** True once the background prefs load has completed. */
64 /** True once the background prefs load has completed. */
65 open val ready: StateFlow<Boolean> = _ready.asStateFlow()
65 open val ready: StateFlow<Boolean> = _ready.asStateFlow()
66
66
67+ /** Keys written in-memory (set / cleared / removed) *before* the async prefs load
68+ * finished. For these the in-memory state is authoritative — the load must not
69+ * resurrect a persisted value the user has since cleared, which a plain putAll(current)
70+ * merge cannot express (it can add/override, but not delete). Consulted once, in the
71+ * init merge. Separate sets because drafts and follow-ups share the session-id key space
72+ * but live under different prefs keys. */
73+ // ConcurrentHashMap-backed sets: weakly-consistent iteration so the init merge's forEach
74+ // can't throw ConcurrentModificationException if a mutator adds a key concurrently.
75+ private val touchedDraftKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
76+ private val touchedFollowUpKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
77+
78+ private fun markDraftTouched(sessionId: String) {
79+ if (!_ready.value) touchedDraftKeys.add(sessionId)
80+ }
81+ private fun markFollowUpTouched(sessionId: String) {
82+ if (!_ready.value) touchedFollowUpKeys.add(sessionId)
83+ }
84+
67 init {
85 init {
68 // Eagerly load the prefs file on a background thread so the first main-thread
86 // Eagerly load the prefs file on a background thread so the first main-thread
69 // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
87 // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
@@ -84,9 +102,21 @@ open class DraftStore private constructor(
84 // Merge instead of overwriting: any in-memory writes that arrived before
102 // Merge instead of overwriting: any in-memory writes that arrived before
85 // the background load completed (e.g. a share-intent draft injected via
103 // the background load completed (e.g. a share-intent draft injected via
86 // setImmediate) take priority over the persisted snapshot so they aren't
104 // setImmediate) take priority over the persisted snapshot so they aren't
87− // clobbered.
105+ // clobbered. For keys the user explicitly touched before the load finished,
106+ // drop the persisted entry first so a value they *cleared* isn't resurrected
107+ // (the cleared key is absent from `current`, so putAll alone can't remove it).
88− _drafts.update { current -> drafts.apply { putAll(current) } }
108+ _drafts.update { current ->
109+ drafts.apply {
110+ touchedDraftKeys.forEach { remove(it) }
111+ putAll(current)
112+ }
113+ }
89− _followUps.update { current -> followUps.apply { putAll(current) } }
114+ _followUps.update { current ->
115+ followUps.apply {
116+ touchedFollowUpKeys.forEach { remove(it) }
117+ putAll(current)
118+ }
119+ }
90 _ready.value = true
120 _ready.value = true
91 }
121 }
92 } else {
122 } else {
@@ -105,6 +135,7 @@ open class DraftStore private constructor(
105 * so a follow-up queued just before process death is flushed before the process exits.
135 * so a follow-up queued just before process death is flushed before the process exits.
106 * A blank [text] clears the stored follow-up. */
136 * A blank [text] clears the stored follow-up. */
107 open fun flushFollowUp(sessionId: String, text: String) {
137 open fun flushFollowUp(sessionId: String, text: String) {
138+ markFollowUpTouched(sessionId)
108 _followUps.update { current ->
139 _followUps.update { current ->
109 val updated = current.toMutableMap()
140 val updated = current.toMutableMap()
110 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
141 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
@@ -130,6 +161,7 @@ open class DraftStore private constructor(
130 * Used when navigation must see the new value synchronously (e.g. share-intent
161 * Used when navigation must see the new value synchronously (e.g. share-intent
131 * injection before ChatScreen composes). Call [set] afterwards to persist. */
162 * injection before ChatScreen composes). Call [set] afterwards to persist. */
132 open fun setImmediate(sessionId: String, text: String) {
163 open fun setImmediate(sessionId: String, text: String) {
164+ markDraftTouched(sessionId)
133 _drafts.update { current ->
165 _drafts.update { current ->
134 val updated = current.toMutableMap()
166 val updated = current.toMutableMap()
135 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
167 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
@@ -138,6 +170,7 @@ open class DraftStore private constructor(
138 }
170 }
139
171
140 open suspend fun set(sessionId: String, text: String) {
172 open suspend fun set(sessionId: String, text: String) {
173+ markDraftTouched(sessionId)
141 _drafts.update { current ->
174 _drafts.update { current ->
142 val updated = current.toMutableMap()
175 val updated = current.toMutableMap()
143 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
176 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
@@ -155,6 +188,8 @@ open class DraftStore private constructor(
155 /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
188 /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
156 * Also clears any queued follow-up so a deleted session leaves nothing behind. */
189 * Also clears any queued follow-up so a deleted session leaves nothing behind. */
157 open suspend fun remove(sessionId: String) {
190 open suspend fun remove(sessionId: String) {
191+ markDraftTouched(sessionId)
192+ markFollowUpTouched(sessionId)
158 _drafts.update { current ->
193 _drafts.update { current ->
159 val updated = current.toMutableMap()
194 val updated = current.toMutableMap()
160 updated.remove(sessionId)
195 updated.remove(sessionId)
@@ -187,6 +222,7 @@ open class DraftStore private constructor(
187 * preferable to an ANR.
222 * preferable to an ANR.
188 */
223 */
189 open fun flushDraft(sessionId: String, text: String) {
224 open fun flushDraft(sessionId: String, text: String) {
225+ markDraftTouched(sessionId)
190 _drafts.update { current ->
226 _drafts.update { current ->
191 val updated = current.toMutableMap()
227 val updated = current.toMutableMap()
192 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
228 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
@@ -215,7 +251,8 @@ open class DraftStore private constructor(
215
251
216 private companion object {
252 private companion object {
217 /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
253 /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
218− * prefs file. Uses a NUL so it can't collide with a real session id. */
254+ * prefs file. The leading/trailing spaces can't collide with a real opencode session
255+ * id (which are non-blank, space-free tokens like "ses_..."). */
219 const val FOLLOWUP_PREFIX = "followup"
256 const val FOLLOWUP_PREFIX = "followup"
220 }
257 }
221
258
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+43−21
@@ -11,6 +11,8 @@ import androidx.security.crypto.MasterKey
11import soy.iko.opencode.data.model.ServerProfile
11import soy.iko.opencode.data.model.ServerProfile
12import soy.iko.opencode.data.network.OpencodeJson
12import soy.iko.opencode.data.network.OpencodeJson
13import kotlinx.coroutines.Dispatchers
13import kotlinx.coroutines.Dispatchers
14+import kotlinx.coroutines.sync.Mutex
15+import kotlinx.coroutines.sync.withLock
14import kotlinx.coroutines.flow.Flow
16import kotlinx.coroutines.flow.Flow
15import kotlinx.coroutines.flow.map
17import kotlinx.coroutines.flow.map
16import kotlinx.coroutines.withContext
18import kotlinx.coroutines.withContext
@@ -33,8 +35,12 @@ open class ProfileStore private constructor(
33
35
34 private val profilesKey = stringPreferencesKey("profiles_json")
36 private val profilesKey = stringPreferencesKey("profiles_json")
35
37
36− /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every DataStore emission. */
38+ /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every
37− private val migrationDone = java.util.concurrent.atomic.AtomicBoolean(false)
39+ * DataStore emission. The mutex serializes concurrent callers so a second collector waits
40+ * for the migration's writes to land (rather than skipping ahead and reading empty secure
41+ * prefs mid-migration); [migrationDone] is only set true *after* the writes complete. */
42+ private val migrationMutex = Mutex()
43+ @Volatile private var migrationDone = false
38
44
39 private val securePrefs: SharedPreferences? by lazy {
45 private val securePrefs: SharedPreferences? by lazy {
40 val ctx = appContext ?: return@lazy null
46 val ctx = appContext ?: return@lazy null
@@ -71,26 +77,42 @@ open class ProfileStore private constructor(
71 * then clear the fallback. Runs at most once per process — subsequent calls
77 * then clear the fallback. Runs at most once per process — subsequent calls
72 * are no-ops — so DataStore re-emissions don't repeatedly scan SharedPreferences. */
78 * are no-ops — so DataStore re-emissions don't repeatedly scan SharedPreferences. */
73 private suspend fun migrateFallbackPasswords() {
79 private suspend fun migrateFallbackPasswords() {
74− if (!migrationDone.compareAndSet(false, true)) return
80+ if (migrationDone) return
81+ migrationMutex.withLock {
82+ // Re-check inside the lock: a concurrent caller may have completed the migration
83+ // while we were waiting. Serializing here (rather than a bare compareAndSet that
84+ // marks done before doing the work) ensures a second collector doesn't read the
85+ // secure store before the first caller's write has populated it.
86+ if (migrationDone) return
75− val secure = securePrefs ?: return
87+ val secure = securePrefs
88+ if (secure == null) {
89+ // No secure store to migrate into; nothing to do, and no point retrying.
90+ migrationDone = true
91+ return
92+ }
76− withContext(Dispatchers.IO) {
93+ withContext(Dispatchers.IO) {
77− val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
94+ val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
78− if (fallbackKeys.isEmpty()) return@withContext
95+ if (fallbackKeys.isNotEmpty()) {
79− // Write passwords to the secure store FIRST, then remove them from the
96+ // Write passwords to the secure store FIRST, then remove them from the
80− // plaintext fallback. SharedPreferences.apply() persists to disk
97+ // plaintext fallback. SharedPreferences.apply() persists to disk
81− // asynchronously, so removing from fallback before the secure batch lands
98+ // asynchronously, so removing from fallback before the secure batch lands
82− // could lose the password if the process dies between the two writes.
99+ // could lose the password if the process dies between the two writes.
83− // Writing secure first makes the worst case a harmless duplicate (both
100+ // Writing secure first makes the worst case a harmless duplicate (both
84− // stores hold it; the migration is idempotent) rather than data loss.
101+ // stores hold it; the migration is idempotent) rather than data loss.
85− secure.edit().apply {
102+ secure.edit().apply {
86− for (key in fallbackKeys) {
103+ for (key in fallbackKeys) {
87− val pw = fallbackPrefs.getString(key, null) ?: continue
104+ val pw = fallbackPrefs.getString(key, null) ?: continue
88− putString(key, pw)
105+ putString(key, pw)
106+ }
107+ }.apply()
108+ fallbackPrefs.edit().apply {
109+ for (key in fallbackKeys) remove(key)
110+ }.apply()
89 }
111 }
90− }.apply()
112+ }
91− fallbackPrefs.edit().apply {
113+ // Only mark done once the writes are in the secure store's in-memory cache (apply()
92− for (key in fallbackKeys) remove(key)
114+ // updates it synchronously), so the next collector past the mutex reads real values.
93− }.apply()
115+ migrationDone = true
94 }
116 }
95 }
117 }
96
118
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+53−7
@@ -80,6 +80,11 @@ open class SessionRepository(
80 }
80 }
81 }
81 }
82
82
83+ // Seed generation counter, shared between the initial load and the reconnect
84+ // re-seed. Guards against a stale fetch (from an earlier reconnect, or from a slow
85+ // initial load) clobbering a newer seed. Only mutated/read under [lock].
86+ var seedGeneration = 0
87+
83 // Re-seed from REST when the SSE stream reconnects after a drop. The REST fetch
88 // Re-seed from REST when the SSE stream reconnects after a drop. The REST fetch
84 // is performed *outside* the lock so events arriving via SSE during the fetch are
89 // is performed *outside* the lock so events arriving via SSE during the fetch are
85 // not blocked. The seed itself is under the lock; a seed generation counter ensures
90 // not blocked. The seed itself is under the lock; a seed generation counter ensures
@@ -95,15 +100,18 @@ open class SessionRepository(
95 // newer one: if two reconnects fire in quick succession, the older fetch
100 // newer one: if two reconnects fire in quick succession, the older fetch
96 // (which returns later) is discarded because its generation no longer matches.
101 // (which returns later) is discarded because its generation no longer matches.
97 var hasConnectedBefore = false
102 var hasConnectedBefore = false
98− var seedGeneration = 0
99 eventStream.state.collect { state ->
103 eventStream.state.collect { state ->
100 if (state == EventStreamClient.ConnectionState.Connected) {
104 if (state == EventStreamClient.ConnectionState.Connected) {
101 if (hasConnectedBefore) {
105 if (hasConnectedBefore) {
102− val generation = ++seedGeneration
103 // Pivot before the fetch: parts already in memory predate this
106 // Pivot before the fetch: parts already in memory predate this
104 // snapshot and must yield to it, while parts that stream in during
107 // snapshot and must yield to it, while parts that stream in during
105 // the fetch (added to streamedSincePivot via reduce) outrank it.
108 // the fetch (added to streamedSincePivot via reduce) outrank it.
109+ // Bump the generation under the same lock so the compare in the
110+ // initial seed sees a consistent value.
111+ val generation = lock.withLock {
106− lock.withLock { store.beginReseed() }
112+ store.beginReseed()
113+ ++seedGeneration
114+ }
107 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
115 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
108 .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
116 .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
109 .getOrDefault(emptyList())
117 .getOrDefault(emptyList())
@@ -129,8 +137,18 @@ open class SessionRepository(
129 val initial = runCatchingCancellable { api.listMessages(sessionId) }
137 val initial = runCatchingCancellable { api.listMessages(sessionId) }
130 .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
138 .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
131 .getOrDefault(emptyList())
139 .getOrDefault(emptyList())
140+ // Apply the initial seed only if no reconnect re-seed has run yet (generation still 0).
141+ // A re-seed is always a fresher full snapshot, so if the (possibly slow) initial fetch
142+ // returns after one has applied, seeding this older REST data would clobber the newer
143+ // state — reverting a completed run to "running", regressing cost/token/part state —
144+ // until the next SSE event or reconnect healed it.
145+ val seeded = lock.withLock {
146+ if (seedGeneration == 0) {
132− lock.withLock { store.seed(initial) }
147+ store.seed(initial)
148+ true
149+ } else false
150+ }
133− publish()
151+ if (seeded) publish()
134
152
135 // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
153 // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
136 // taken under the lock so a reader never sees a half-reduced state; send() runs
154 // taken under the lock so a reader never sees a half-reduced state; send() runs
@@ -244,6 +262,13 @@ internal class MessageStore {
244 }
262 }
245 }
263 }
246 }
264 }
265+ // A part that streamed in *before* this initial load (observeMessages subscribes to
266+ // SSE before fetching) inserts its message at the front of the insertion-ordered map,
267+ // and the merge above keeps that position — pinning a running message above the older
268+ // history that seed() inserts afterward. Restore chronological order by creation time
269+ // FIRST, so eviction below drops the genuinely-oldest entries: evictOldMessages()
270+ // removes from the map's front, which before reordering is the newest streamed message.
271+ if (reorderByTime()) changed = true
247 if (evictOldMessages()) changed = true
272 if (evictOldMessages()) changed = true
248 // Reset the pivot: everything just merged is now the baseline, so only parts/info
273 // Reset the pivot: everything just merged is now the baseline, so only parts/info
249 // that stream in *after* this seed can outrank the next reconnect's snapshot.
274 // that stream in *after* this seed can outrank the next reconnect's snapshot.
@@ -252,6 +277,19 @@ internal class MessageStore {
252 return changed
277 return changed
253 }
278 }
254
279
280+ /** Reorder the message map by message creation time (stable). Ties and entries with no
281+ * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
282+ * relative order, so such a message stays last. Preserves each entry's map key so the
283+ * synthetic UnknownMessage keys aren't collapsed. Returns true if the order changed. */
284+ private fun reorderByTime(): Boolean {
285+ val entries = messages.entries.map { it.key to it.value }
286+ val sorted = entries.sortedBy { it.second.info.time?.created ?: Long.MAX_VALUE }
287+ if (sorted.map { it.first } == entries.map { it.first }) return false
288+ messages.clear()
289+ for ((k, v) in sorted) messages[k] = v
290+ return true
291+ }
292+
255 /** Returns true if the state changed (and a new snapshot should be published). */
293 /** Returns true if the state changed (and a new snapshot should be published). */
256 fun reduce(sessionId: String, event: BusEvent): Boolean {
294 fun reduce(sessionId: String, event: BusEvent): Boolean {
257 return when (event) {
295 return when (event) {
@@ -319,7 +357,10 @@ internal class MessageStore {
319 private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
357 private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
320 val id = event.properties.messageID ?: return false
358 val id = event.properties.messageID ?: return false
321 if (!matchesSession(event.properties.sessionID, sessionId)) return false
359 if (!matchesSession(event.properties.sessionID, sessionId)) return false
322− return messages.remove(id) != null
360+ val removed = messages.remove(id) ?: return false
361+ messageInfoUpdatedSincePivot.remove(id)
362+ removed.parts.forEach { streamedSincePivot.remove(it.id) }
363+ return true
323 }
364 }
324
365
325 private fun upsertPart(messageId: String, part: Part): Boolean {
366 private fun upsertPart(messageId: String, part: Part): Boolean {
@@ -358,7 +399,12 @@ internal class MessageStore {
358 var evicted = false
399 var evicted = false
359 while (messages.size > maxMessages) {
400 while (messages.size > maxMessages) {
360 val oldestKey = messages.keys.iterator().next()
401 val oldestKey = messages.keys.iterator().next()
361− messages.remove(oldestKey)
402+ val removed = messages.remove(oldestKey)
403+ // Prune the evicted message's pivot bookkeeping so these sets don't grow
404+ // unbounded over a long-running session that never reconnects (they're only
405+ // otherwise cleared on reseed/seed).
406+ messageInfoUpdatedSincePivot.remove(oldestKey)
407+ removed?.parts?.forEach { streamedSincePivot.remove(it.id) }
362 evicted = true
408 evicted = true
363 }
409 }
364 return evicted
410 return evicted
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+137−2
@@ -3,11 +3,14 @@ package soy.iko.opencode.di
3import android.content.Context
3import android.content.Context
4import android.net.ConnectivityManager
4import android.net.ConnectivityManager
5import android.net.Network
5import android.net.Network
6+import android.net.NetworkCapabilities
6import android.net.NetworkRequest
7import android.net.NetworkRequest
7import android.util.Log
8import android.util.Log
9+import soy.iko.opencode.data.model.AssistantMessage
8import soy.iko.opencode.data.model.BusEvent
10import soy.iko.opencode.data.model.BusEvent
9import soy.iko.opencode.data.model.MessagePartUpdated
11import soy.iko.opencode.data.model.MessagePartUpdated
10import soy.iko.opencode.data.model.MessageUpdated
12import soy.iko.opencode.data.model.MessageUpdated
13+import soy.iko.opencode.data.model.StepFinishPart
11import soy.iko.opencode.data.model.ServerProfile
14import soy.iko.opencode.data.model.ServerProfile
12import soy.iko.opencode.data.model.SessionIdle
15import soy.iko.opencode.data.model.SessionIdle
13import soy.iko.opencode.data.repo.DraftStore
16import soy.iko.opencode.data.repo.DraftStore
@@ -35,6 +38,7 @@ import kotlinx.coroutines.flow.StateFlow
35import kotlinx.coroutines.flow.asStateFlow
38import kotlinx.coroutines.flow.asStateFlow
36import kotlinx.coroutines.flow.emptyFlow
39import kotlinx.coroutines.flow.emptyFlow
37import kotlinx.coroutines.flow.first
40import kotlinx.coroutines.flow.first
41+import kotlinx.coroutines.Job
38import kotlinx.coroutines.flow.flatMapLatest
42import kotlinx.coroutines.flow.flatMapLatest
39import kotlinx.coroutines.flow.update
43import kotlinx.coroutines.flow.update
40import kotlinx.coroutines.isActive
44import kotlinx.coroutines.isActive
@@ -297,7 +301,7 @@ open class AppContainer private constructor(
297 }
301 }
298 // Track sessions actively streaming so we know which idle events
302 // Track sessions actively streaming so we know which idle events
299 // represent a finished run worth notifying about.
303 // represent a finished run worth notifying about.
300− if (event is MessagePartUpdated || event is MessageUpdated) {
304+ if (isLiveRunActivity(event)) {
301 synchronized(activeRuns) {
305 synchronized(activeRuns) {
302 if (activeRuns.size >= activeRunsLimit) {
306 if (activeRuns.size >= activeRunsLimit) {
303 activeRuns.remove(activeRuns.iterator().next())
307 activeRuns.remove(activeRuns.iterator().next())
@@ -313,6 +317,24 @@ open class AppContainer private constructor(
313 }
317 }
314 }
318 }
315
319
320+ /** True if [event] is live streaming activity worth tracking as an active run. A
321+ * trailing message.updated that lands *after* the run finished (final cost/token totals,
322+ * carrying a completion time) is NOT live streaming: with no further SessionIdle to
323+ * follow it, re-adding the session would pin anyRunActive true indefinitely. */
324+ private fun isLiveRunActivity(event: BusEvent): Boolean {
325+ if (event !is MessagePartUpdated && event !is MessageUpdated) return false
326+ val info = (event as? MessageUpdated)?.properties?.info
327+ if (info is AssistantMessage && info.isComplete) return false
328+ // A step-finish part is the trailing completion marker for a run (it carries the
329+ // final cost/token totals) and arrives after the last stream delta. Like a completed
330+ // message.updated, treating it as live would re-add the session to activeRuns with no
331+ // following SessionIdle to clear it — pinning anyRunActive true indefinitely when such
332+ // a part is replayed/reordered after the run already went idle.
333+ val part = (event as? MessagePartUpdated)?.properties?.part
334+ if (part is StepFinishPart) return false
335+ return true
336+ }
337+
316 /** Session ids currently streaming an assistant run (best-effort, in-process). */
338 /** Session ids currently streaming an assistant run (best-effort, in-process). */
317 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
339 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
318
340
@@ -379,7 +401,13 @@ open class AppContainer private constructor(
379 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
401 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
380 override fun onLost(network: Network) { _isOnline.value = cm.activeNetwork != null }
402 override fun onLost(network: Network) { _isOnline.value = cm.activeNetwork != null }
381 }
403 }
404+ // Only match networks that actually provide internet — a capability-less request
405+ // fires onAvailable for transports that can't reach the server (and would trigger
406+ // spurious reconnects), so require NET_CAPABILITY_INTERNET.
407+ val request = NetworkRequest.Builder()
408+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
409+ .build()
382− runCatching { cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback) }
410+ runCatching { cm.registerNetworkCallback(request, callback) }
383 .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
411 .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
384 return callback
412 return callback
385 }
413 }
@@ -411,7 +439,114 @@ open class AppContainer private constructor(
411 connectionMutex.withLock {
439 connectionMutex.withLock {
412 _activeConnection.value?.close()
440 _activeConnection.value?.close()
413 _activeConnection.value = null
441 _activeConnection.value = null
442+ // Reset run/unread state on disconnect too. With no SSE stream there's no
443+ // SessionIdle to drain activeRuns, so without this the working indicator
444+ // (anyRunActive) and unread badges would stay pinned until the next connect().
445+ activeRuns.clear()
446+ _anyRunActive.value = false
447+ _unread.value = emptyMap()
448+ unreadMessageIds.clear()
449+ }
450+ }
451+
452+ /** Deferred profile deletions keyed by id, run on the process-lived [appScope] (not a
453+ * ViewModel scope) so navigating away during the undo window still commits the delete
454+ * instead of silently cancelling it — mirroring [CrashLogger.scheduleDelete]. */
455+ private val pendingProfileDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
456+
457+ /**
458+ * Schedule the profile [id] for deletion after [delayMs], cancellable via
459+ * [cancelProfileDelete] (the Undo action). [onDeleted]/[onError] run on [appScope]
460+ * after the delete resolves so the caller can clear its optimistic-hide state. A
461+ * repeated schedule for the same id replaces the prior timer.
462+ */
463+ open fun scheduleProfileDelete(
464+ id: String,
465+ delayMs: Long,
466+ onDeleted: () -> Unit,
467+ onError: (Throwable) -> Unit,
468+ ) {
469+ val job = appScope.launch {
470+ delay(delayMs)
471+ // Claim the delete by removing our own entry BEFORE committing it — the atomic
472+ // remove is the sync point with cancelProfileDelete. Losing the race (an undo
473+ // removed us first) means we skip, honouring cancel's `true`; deleting first
474+ // would let a cancel land in the gap and falsely report undo of a gone profile.
475+ // remove(id, thisJob) also no-ops for a stale job after a reschedule.
476+ val claimed = coroutineContext[Job]?.let { pendingProfileDeletes.remove(id, it) } == true
477+ if (!claimed) return@launch
478+ runCatchingCancellable { profileStore.delete(id) }
479+ .onSuccess { onDeleted() }
480+ .onFailure { onError(it) }
481+ }
482+ pendingProfileDeletes.put(id, job)?.cancel()
483+ }
484+
485+ /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
486+ * still pending (undo succeeded), false if the delete had already fired. */
487+ open fun cancelProfileDelete(id: String): Boolean {
488+ val job = pendingProfileDeletes.remove(id) ?: return false
489+ job.cancel()
490+ return true
491+ }
492+
493+ /** Deferred session deletions keyed by id, run on the process-lived [appScope] (not a
494+ * ViewModel scope) so navigating away during the undo window still commits the delete
495+ * instead of silently cancelling it (which would leave the session deleted-in-UI but
496+ * alive on the server, reappearing on the next refresh). */
497+ private val pendingSessionDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
498+
499+ /**
500+ * Schedule session [id] for deletion after [delayMs], cancellable via [cancelSessionDelete]
501+ * (the Undo action). Also clears the session's stored draft on a successful delete.
502+ * [onDeleted]/[onError] run on [appScope] after the delete resolves so the caller can update
503+ * its optimistic-hide state. A repeated schedule for the same id replaces the prior timer.
504+ *
505+ * The owning connection is captured now, at schedule time — NOT re-resolved when the timer
506+ * fires. Otherwise a server switch during the undo window would send the delete to whichever
507+ * server happens to be active at fire time: the original session would never be deleted (its
508+ * row reappears on the next refresh) and, in the rare id-collision case, an unrelated session
509+ * on the new server could be deleted instead. A disconnect closes the captured connection's
510+ * HTTP client, so a delete deferred across a disconnect fails and is surfaced via [onError]
511+ * (the session cannot be deleted on a server we're no longer connected to). No-ops (as an
512+ * error) if there is no active connection at schedule time.
513+ */
514+ open fun scheduleSessionDelete(
515+ id: String,
516+ delayMs: Long,
517+ onDeleted: () -> Unit,
518+ onError: (Throwable) -> Unit,
519+ ) {
520+ val conn = _activeConnection.value
521+ val job = appScope.launch {
522+ delay(delayMs)
523+ // Claim the delete by removing our own entry BEFORE committing it — the atomic
524+ // remove is the sync point with cancelSessionDelete. Losing the race (an undo
525+ // removed us first) means we skip, honouring cancel's `true`; deleting first
526+ // would let a cancel land in the gap and falsely report undo of a gone session.
527+ // remove(id, thisJob) also no-ops for a stale job after a reschedule.
528+ val claimed = coroutineContext[Job]?.let { pendingSessionDeletes.remove(id, it) } == true
529+ if (!claimed) return@launch
530+ if (conn == null) {
531+ onError(IllegalStateException("No active connection"))
532+ } else {
533+ runCatchingCancellable { conn.repository.deleteSession(id) }
534+ .onSuccess {
535+ draftStore.remove(id)
536+ onDeleted()
537+ }
538+ .onFailure { onError(it) }
539+ }
414 }
540 }
541+ pendingSessionDeletes.put(id, job)?.cancel()
542+ }
543+
544+ /** Cancel a pending deferred session delete (the Undo action). Returns true if it was
545+ * still pending (undo succeeded), false if the delete had already fired. */
546+ open fun cancelSessionDelete(id: String): Boolean {
547+ val job = pendingSessionDeletes.remove(id) ?: return false
548+ job.cancel()
549+ return true
415 }
550 }
416
551
417 /**
552 /**
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+31−11
@@ -70,20 +70,30 @@ fun OpencodeApp(container: AppContainer) {
70 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
70 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
71
71
72 // Open a session requested by a notification tap or deep link, once connected.
72 // Open a session requested by a notification tap or deep link, once connected.
73− // In two-pane mode the detail pane consumes the request instead of navigating.
74− // Ensure SESSIONS is on the back stack below the chat destination so backing
75− // out of the chat lands on the session list (not the server list). The
76− // ServerListScreen's auto-connect also navigates to SESSIONS; without this
77− // popUpTo guard the two navigations race and can leave the stack as
78− // SERVERS -> chat (missing SESSIONS) or chat buried under SESSIONS.
79 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
73 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
80 val id = pendingOpenSession ?: return@LaunchedEffect
74 val id = pendingOpenSession ?: return@LaunchedEffect
75+ if (connection == null) return@LaunchedEffect
76+ // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
77+ // route) consumes the request and opens it in the detail pane. But it only
78+ // exists while the NavHost is on SESSIONS, so if the user is currently on
79+ // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
80+ // pending request is never consumed and the session never opens. Don't consume
81+ // here; let TwoPaneSessionChat's effect do it once it's composed.
82+ if (isTwoPane) {
83+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
84+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
85+ }
81− if (connection == null || isTwoPane) return@LaunchedEffect
86+ return@LaunchedEffect
87+ }
82 container.consumePendingOpenSession()
88 container.consumePendingOpenSession()
89+ // Ensure SESSIONS sits on the back stack below the chat destination so backing
90+ // out of the chat lands on the session list (not the server list). Bring SESSIONS
91+ // to front (or push it if a cold-start deep link left only SERVERS on the stack),
92+ // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
93+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
83− navController.navigate(Routes.chat(id)) {
94+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
84− launchSingleTop = true
85− popUpTo(Routes.SERVERS) { inclusive = false }
86 }
95 }
96+ navController.navigate(Routes.chat(id)) { launchSingleTop = true }
87 }
97 }
88
98
89 NavHost(
99 NavHost(
@@ -100,7 +110,17 @@ fun OpencodeApp(container: AppContainer) {
100 composable(Routes.SERVERS) {
110 composable(Routes.SERVERS) {
101 ServerListScreen(
111 ServerListScreen(
102 container = container,
112 container = container,
113+ onConnected = {
114+ // Only leave the server list if we're still on it. A pending deep-link /
115+ // notification open sets activeConnection (which drives the open-session
116+ // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
117+ // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
118+ // on top of the chat it just opened, burying it. Order-independent: if the
119+ // deep link already navigated away from SERVERS, this is a no-op.
120+ if (navController.currentDestination?.route == Routes.SERVERS) {
103− onConnected = { navController.navigate(Routes.SESSIONS) },
121+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
122+ }
123+ },
104 onAddProfile = { navController.navigate(Routes.serverEdit()) },
124 onAddProfile = { navController.navigate(Routes.serverEdit()) },
105 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
125 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
106 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
126 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+13−1
@@ -109,7 +109,17 @@ fun AgentPickerSheet(
109 it.displayDescription.contains(q, ignoreCase = true)
109 it.displayDescription.contains(q, ignoreCase = true)
110 }
110 }
111 }
111 }
112+ // The "Default" option participates in the search too: show it when the query is
113+ // empty or matches its label/description. Without this it either always showed
114+ // (ignoring the filter) or, when a query matched no named agent, disappeared with
115+ // the whole list — leaving the default unselectable.
116+ val defaultLabel = stringResource(R.string.default_agent)
117+ val defaultDesc = stringResource(R.string.default_agent_desc)
118+ val q = query.trim()
119+ val defaultMatches = q.isEmpty() ||
120+ defaultLabel.contains(q, ignoreCase = true) ||
121+ defaultDesc.contains(q, ignoreCase = true)
112− if (filtered.isEmpty()) {
122+ if (filtered.isEmpty() && !defaultMatches) {
113 Text(
123 Text(
114 stringResource(R.string.no_agents_match, query.trim()),
124 stringResource(R.string.no_agents_match, query.trim()),
115 style = MaterialTheme.typography.bodyMedium,
125 style = MaterialTheme.typography.bodyMedium,
@@ -118,6 +128,7 @@ fun AgentPickerSheet(
118 )
128 )
119 } else {
129 } else {
120 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
130 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
131+ if (defaultMatches) {
121 item(key = "__default") {
132 item(key = "__default") {
122 Column(
133 Column(
123 modifier = Modifier
134 modifier = Modifier
@@ -151,6 +162,7 @@ fun AgentPickerSheet(
151 )
162 )
152 }
163 }
153 }
164 }
165+ }
154 items(filtered, key = { it.name }) { agent ->
166 items(filtered, key = { it.name }) { agent ->
155 val isSelected = agent.name == selected
167 val isSelected = agent.name == selected
156 Column(
168 Column(
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+18−5
@@ -208,11 +208,13 @@ fun ChatScreen(
208 }
208 }
209
209
210 LaunchedEffect(Unit) {
210 LaunchedEffect(Unit) {
211− vm.errorEvents.collect { msg ->
211+ vm.errorEvents.collect { event ->
212− val result = if (vm.failedDraft.value != null) {
212+ // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
213+ // message-load failure) would silently re-submit the last prompt on tap.
214+ val result = if (event.retryable) {
213− snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
215+ snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
214 } else {
216 } else {
215− snackbar.showSnackbar(msg)
217+ snackbar.showSnackbar(event.message)
216 }
218 }
217 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
219 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
218 }
220 }
@@ -398,8 +400,19 @@ fun ChatScreen(
398 }
400 }
399 }
401 }
400
402
403+ // Jump to the newest message once, when the conversation first loads. Guarded by a
404+ // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
405+ // (rotation) — rememberLazyListState restores the user's scroll offset, and an
406+ // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
407+ var didInitialScroll by rememberSaveable { mutableStateOf(false) }
401 LaunchedEffect(messages.isNotEmpty()) {
408 LaunchedEffect(messages.isNotEmpty()) {
409+ if (messages.isNotEmpty() && !didInitialScroll) {
410+ // When a run is active the LazyColumn has a trailing "__typing" row at
411+ // messages.size; target it (not messages.lastIndex) so deep-linking into a
412+ // running session lands truly pinned to bottom and streaming auto-scroll engages.
402− if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
413+ listState.scrollToItem(if (running) messages.size else messages.lastIndex)
414+ didInitialScroll = true
415+ }
403 }
416 }
404
417
405 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
418 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+72−23
@@ -25,13 +25,13 @@ import soy.iko.opencode.util.safeExceptionSummary
25import android.util.Log
25import android.util.Log
26import kotlinx.coroutines.ExperimentalCoroutinesApi
26import kotlinx.coroutines.ExperimentalCoroutinesApi
27import kotlinx.coroutines.channels.BufferOverflow
27import kotlinx.coroutines.channels.BufferOverflow
28−import kotlinx.coroutines.flow.MutableSharedFlow
28+import kotlinx.coroutines.channels.Channel
29+import kotlinx.coroutines.flow.Flow
29import kotlinx.coroutines.flow.MutableStateFlow
30import kotlinx.coroutines.flow.MutableStateFlow
30−import kotlinx.coroutines.flow.SharedFlow
31import kotlinx.coroutines.flow.SharingStarted
31import kotlinx.coroutines.flow.SharingStarted
32import kotlinx.coroutines.flow.StateFlow
32import kotlinx.coroutines.flow.StateFlow
33−import kotlinx.coroutines.flow.asSharedFlow
34import kotlinx.coroutines.flow.asStateFlow
33import kotlinx.coroutines.flow.asStateFlow
34+import kotlinx.coroutines.flow.receiveAsFlow
35import kotlinx.coroutines.CancellationException
35import kotlinx.coroutines.CancellationException
36import kotlinx.coroutines.flow.collectLatest
36import kotlinx.coroutines.flow.collectLatest
37import kotlinx.coroutines.flow.debounce
37import kotlinx.coroutines.flow.debounce
@@ -48,6 +48,14 @@ import kotlinx.coroutines.flow.stateIn
48import kotlinx.coroutines.delay
48import kotlinx.coroutines.delay
49import kotlinx.coroutines.launch
49import kotlinx.coroutines.launch
50
50
51+/**
52+ * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
53+ * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
54+ * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
55+ * inherit a Retry that silently re-submits the last prompt.
56+ */
57+data class ChatError(val message: String, val retryable: Boolean = false)
58+
51@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
59@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
52class ChatViewModel(
60class ChatViewModel(
53 private val container: AppContainer,
61 private val container: AppContainer,
@@ -73,6 +81,11 @@ class ChatViewModel(
73 * (already showing messages → don't replace the conversation with an error). */
81 * (already showing messages → don't replace the conversation with an error). */
74 private var hasShownMessages = false
82 private var hasShownMessages = false
75
83
84+ /** True once we've surfaced a snackbar for the current message-load failure streak.
85+ * Reset on any successful emission so a persistent failure doesn't spam a fresh
86+ * snackbar every retry cycle (every few seconds). */
87+ private var loadErrorSnackbarShown = false
88+
76 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
89 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
77 * doesn't conflict with the messages flow's loading state. */
90 * doesn't conflict with the messages flow's loading state. */
78 private val _reconnecting = MutableStateFlow(false)
91 private val _reconnecting = MutableStateFlow(false)
@@ -103,6 +116,9 @@ class ChatViewModel(
103 }
116 }
104 .onEach {
117 .onEach {
105 _loading.value = false
118 _loading.value = false
119+ // A value flowed through: the stream recovered, so allow the next distinct
120+ // failure to surface a fresh snackbar again.
121+ loadErrorSnackbarShown = false
106 if (it.isNotEmpty()) {
122 if (it.isNotEmpty()) {
107 hasShownMessages = true
123 hasShownMessages = true
108 _loadError.value = false
124 _loadError.value = false
@@ -115,7 +131,13 @@ class ChatViewModel(
115 // is better surfaced as a snackbar (via errorEvents) than by
131 // is better surfaced as a snackbar (via errorEvents) than by
116 // replacing the visible conversation with an error screen.
132 // replacing the visible conversation with an error screen.
117 if (!hasShownMessages) _loadError.value = true
133 if (!hasShownMessages) _loadError.value = true
134+ // Surface the snackbar only once per failure streak — retryWhen loops
135+ // every retryInitialDelayMs, so emitting here unconditionally would spam a
136+ // new snackbar every few seconds on a persistent load failure.
137+ if (!loadErrorSnackbarShown) {
118− _errorEvents.tryEmit(container.friendlyError(cause))
138+ _errorEvents.trySend(ChatError(container.friendlyError(cause)))
139+ loadErrorSnackbarShown = true
140+ }
119 // Delay before retrying to avoid a tight loop on persistent errors.
141 // Delay before retrying to avoid a tight loop on persistent errors.
120 delay(NetworkConfig.retryInitialDelayMs)
142 delay(NetworkConfig.retryInitialDelayMs)
121 true
143 true
@@ -152,14 +174,16 @@ class ChatViewModel(
152 private val _queuedFollowUp = MutableStateFlow<String?>(null)
174 private val _queuedFollowUp = MutableStateFlow<String?>(null)
153 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
175 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
154
176
155− /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
177+ /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
156− * emission is delivered independently — two rapid failures both get a snackbar
178+ * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
157− * instead of the second silently overwriting the first. */
179+ * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
180+ * composition). Each event is delivered exactly once and not replayed to a
181+ * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
158− private val _errorEvents = MutableSharedFlow<String>(
182+ private val _errorEvents = Channel<ChatError>(
159− extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
183+ capacity = NetworkConfig.snackbarEventBufferCapacity,
160 onBufferOverflow = BufferOverflow.DROP_OLDEST,
184 onBufferOverflow = BufferOverflow.DROP_OLDEST,
161 )
185 )
162− val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
186+ val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
163
187
164 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
188 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
165 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
189 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
@@ -362,7 +386,17 @@ class ChatViewModel(
362 // persist into the new one.
386 // persist into the new one.
363 viewModelScope.launch {
387 viewModelScope.launch {
364 container.activeConnection.collectLatest { conn ->
388 container.activeConnection.collectLatest { conn ->
389+ if (conn == null) {
390+ // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
391+ // arrive to deliver SessionIdle/SessionError, so reset the run state here —
392+ // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
393+ // the RunForegroundService alive, and the Stop button no-ops (abort() early-
394+ // returns with no connection) until a later reconnect happens to clear it.
395+ _running.value = false
396+ _aborting.value = false
397+ _pendingPermission.value = null
365− if (conn == null) return@collectLatest
398+ return@collectLatest
399+ }
366 _running.value = false
400 _running.value = false
367 _pendingPermission.value = null
401 _pendingPermission.value = null
368 _failedDraft.value = null
402 _failedDraft.value = null
@@ -386,8 +420,15 @@ class ChatViewModel(
386 }
420 }
387 if (SessionRepository.isError(event, sessionId)) {
421 if (SessionRepository.isError(event, sessionId)) {
388 _running.value = false
422 _running.value = false
423+ // The run errored, so the queued follow-up won't auto-send.
424+ // Restore it to the input (if free) so the user's typed text
425+ // isn't silently lost; otherwise just clear the queue.
426+ val queued = _queuedFollowUp.value
427+ if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
428+ _draft.value = queued
429+ }
389 setQueuedFollowUp(null)
430 setQueuedFollowUp(null)
390− _errorEvents.tryEmit(container.string(R.string.error_agent_reported))
431+ _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
391 }
432 }
392 when (event) {
433 when (event) {
393 is PermissionUpdated ->
434 is PermissionUpdated ->
@@ -518,7 +559,8 @@ class ChatViewModel(
518 _failedDraft.value = trimmed
559 _failedDraft.value = trimmed
519 // Only restore the draft if the user hasn't typed anything new since.
560 // Only restore the draft if the user hasn't typed anything new since.
520 if (_draft.value.isBlank()) updateDraft(trimmed)
561 if (_draft.value.isBlank()) updateDraft(trimmed)
562+ // Retryable: this is the failed send whose prompt Retry re-submits.
521− _errorEvents.tryEmit(container.friendlyError(it))
563+ _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
522 _running.value = false
564 _running.value = false
523 }.onSuccess {
565 }.onSuccess {
524 suppressDraftPersist.set(false)
566 suppressDraftPersist.set(false)
@@ -600,12 +642,17 @@ class ChatViewModel(
600 /** Invoke a slash-command by name via the server's /command endpoint. */
642 /** Invoke a slash-command by name via the server's /command endpoint. */
601 fun runCommand(command: Command) {
643 fun runCommand(command: Command) {
602 val conn = connection ?: return
644 val conn = connection ?: return
645+ // A run is already in flight — surface feedback instead of silently dropping the
646+ // command (the picker stays openable during a run and dismisses on select).
603− if (!_running.compareAndSet(false, true)) return
647+ if (!_running.compareAndSet(false, true)) {
648+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
649+ return
650+ }
604 viewModelScope.launch {
651 viewModelScope.launch {
605 runCatchingCancellable {
652 runCatchingCancellable {
606 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
653 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
607 }.onFailure {
654 }.onFailure {
608− _errorEvents.tryEmit(container.friendlyError(it))
655+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
609 _running.value = false
656 _running.value = false
610 }
657 }
611 }
658 }
@@ -621,7 +668,7 @@ class ChatViewModel(
621 try {
668 try {
622 runCatchingCancellable { conn.repository.abort(sessionId) }
669 runCatchingCancellable { conn.repository.abort(sessionId) }
623 .onSuccess { _running.value = false }
670 .onSuccess { _running.value = false }
624− .onFailure { _errorEvents.tryEmit(container.friendlyError(it)) }
671+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
625 } finally {
672 } finally {
626 _aborting.value = false
673 _aborting.value = false
627 }
674 }
@@ -642,7 +689,7 @@ class ChatViewModel(
642 .getOrDefault(emptyList())
689 .getOrDefault(emptyList())
643 .firstOrNull()
690 .firstOrNull()
644 if (recent == null) {
691 if (recent == null) {
645− _errorEvents.tryEmit(container.string(R.string.no_servers_to_reconnect))
692+ _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
646 return@launch
693 return@launch
647 }
694 }
648 runCatchingCancellable {
695 runCatchingCancellable {
@@ -650,7 +697,7 @@ class ChatViewModel(
650 conn.api.ping()
697 conn.api.ping()
651 }.onFailure {
698 }.onFailure {
652 container.disconnect()
699 container.disconnect()
653− _errorEvents.tryEmit(container.friendlyError(it))
700+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
654 }
701 }
655 } finally {
702 } finally {
656 _reconnecting.value = false
703 _reconnecting.value = false
@@ -667,7 +714,7 @@ class ChatViewModel(
667 viewModelScope.launch {
714 viewModelScope.launch {
668 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
715 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
669 .onFailure {
716 .onFailure {
670− _errorEvents.tryEmit(container.friendlyError(it))
717+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
671 // Only restore if no new permission has arrived in the meantime.
718 // Only restore if no new permission has arrived in the meantime.
672 if (_pendingPermission.value == null) _pendingPermission.value = permission
719 if (_pendingPermission.value == null) _pendingPermission.value = permission
673 }
720 }
@@ -680,9 +727,11 @@ class ChatViewModel(
680 // asynchronous apply() (not a synchronous commit) so the main thread isn't
727 // asynchronous apply() (not a synchronous commit) so the main thread isn't
681 // blocked on disk I/O — Android's SharedPreferences framework guarantees
728 // blocked on disk I/O — Android's SharedPreferences framework guarantees
682 // pending apply() writes are flushed before the process exits.
729 // pending apply() writes are flushed before the process exits.
683− val pending = _draft.value
730+ // Always flush — including an empty draft. Persistence is otherwise debounced, so if
684− if (pending.isNotEmpty()) {
731+ // the user cleared the input and navigated back within the debounce window, the
732+ // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
733+ // previous non-empty draft. flushDraft() removes the key when the text is blank, so an
734+ // unconditional flush commits the clear instead of resurrecting the deleted text.
685− container.draftStore.flushDraft(sessionId, pending)
735+ container.draftStore.flushDraft(sessionId, _draft.value)
686− }
687 }
736 }
688}
737}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+26−2
@@ -27,10 +27,15 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
27 is AssistantMessage -> "## opencode"
27 is AssistantMessage -> "## opencode"
28 else -> null
28 else -> null
29 } ?: continue
29 } ?: continue
30+ // The assistant's reply text is already Markdown (code fences, bold, headings);
31+ // escaping it would render the export as backslash noise. Only escape user-authored
32+ // text, which isn't expected to be Markdown.
33+ val escapeText = message.info is UserMessage
30 val body = message.parts
34 val body = message.parts
31 .mapNotNull { part ->
35 .mapNotNull { part ->
32 when (part) {
36 when (part) {
33− is TextPart -> part.text.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
37+ is TextPart -> part.text.takeIf { it.isNotBlank() }
38+ ?.let { if (escapeText) escapeMarkdown(it) else it }
34 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
39 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
35 ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
40 ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
36 is ToolPart -> formatToolCall(part)
41 is ToolPart -> formatToolCall(part)
@@ -74,11 +79,30 @@ private fun formatToolCall(part: ToolPart): String? {
74 // blockquote — otherwise lines after the first fall out of the quote.
79 // blockquote — otherwise lines after the first fall out of the quote.
75 if (detail != null) {
80 if (detail != null) {
76 val quoted = detail.lines().joinToString("\n") { "> $it" }
81 val quoted = detail.lines().joinToString("\n") { "> $it" }
82+ // Use a fence longer than the longest backtick run in the content, so tool output
83+ // that itself contains a ``` sequence (shell output echoing markdown, etc.) can't
84+ // prematurely close the block and make the rest of the transcript render broken.
85+ val fence = "`".repeat(maxOf(3, longestBacktickRun(detail) + 1))
77− add(">\n> ```\n$quoted\n> ```")
86+ add(">\n> $fence\n$quoted\n> $fence")
78 }
87 }
79 }.joinToString("\n").takeIf { it.isNotBlank() }
88 }.joinToString("\n").takeIf { it.isNotBlank() }
80}
89}
81
90
91+/** Length of the longest consecutive run of backticks in [text] (0 if none). */
92+private fun longestBacktickRun(text: String): Int {
93+ var max = 0
94+ var current = 0
95+ for (c in text) {
96+ if (c == '`') {
97+ current++
98+ if (current > max) max = current
99+ } else {
100+ current = 0
101+ }
102+ }
103+ return max
104+}
105+
82private fun truncateOutput(output: String): String {
106private fun truncateOutput(output: String): String {
83 if (output.length <= TOOL_OUTPUT_LIMIT) return output
107 if (output.length <= TOOL_OUTPUT_LIMIT) return output
84 return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
108 return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+10−3
@@ -178,8 +178,13 @@ private fun AssistantBlock(
178 )
178 )
179 }
179 }
180 }
180 }
181− for (part in message.parts) {
181+ message.parts.forEachIndexed { index, part ->
182+ // Only the final part of a running message is actively streaming; earlier parts
183+ // (a finished reasoning block, a completed tool call) are already done, so passing
184+ // isRunning to all of them would keep a completed reasoning block showing the
185+ // "Thinking…" spinner until the whole message finishes.
186+ val partStreaming = isRunning && index == message.parts.lastIndex
182− PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
187+ PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
183 }
188 }
184 if (info is AssistantMessage) {
189 if (info is AssistantMessage) {
185 val cost = info.cost
190 val cost = info.cost
@@ -193,7 +198,9 @@ private fun AssistantBlock(
193 val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, costShort, costLong) {
198 val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, costShort, costLong) {
194 if (!info.isComplete || (cost == null && tokens == null)) null
199 if (!info.isComplete || (cost == null && tokens == null)) null
195 else buildList {
200 else buildList {
201+ // Skip an all-zero token count (e.g. a completed message that reported no
202+ // usage) so the bubble doesn't show a meaningless "0 in · 0 out".
196− tokens?.let { add(formatTokens(it, tokenFormat)) }
203+ tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
197 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
204 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
198 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
205 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
199 }
206 }
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+1−1
@@ -111,7 +111,7 @@ fun ModelPickerSheet(
111 }
111 }
112 if (filtered.isEmpty()) {
112 if (filtered.isEmpty()) {
113 Text(
113 Text(
114− stringResource(R.string.no_models_match),
114+ stringResource(R.string.no_models_match, query.trim()),
115 style = MaterialTheme.typography.bodyMedium,
115 style = MaterialTheme.typography.bodyMedium,
116 color = MaterialTheme.colorScheme.onSurfaceVariant,
116 color = MaterialTheme.colorScheme.onSurfaceVariant,
117 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
117 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+5
@@ -263,6 +263,11 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
263 detail = pretty,
263 detail = pretty,
264 isDiff = false,
264 isDiff = false,
265 keySuffix = "input",
265 keySuffix = "input",
266+ // The input block has a label row whose collapse toggle announces its state
267+ // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
268+ // rather than an empty state (the defaults are "").
269+ expandedState = stringResource(R.string.state_expanded),
270+ collapsedState = stringResource(R.string.state_collapsed),
266 )
271 )
267 }
272 }
268 val detail = when (val s = part.state) {
273 val detail = when (val s = part.state) {
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+5−1
@@ -42,7 +42,11 @@ fun PermissionDialog(
42 val haptics = LocalHapticFeedback.current
42 val haptics = LocalHapticFeedback.current
43 // Guard against double-respond: back press + button tap, or rapid double-tap,
43 // Guard against double-respond: back press + button tap, or rapid double-tap,
44 // could call onRespond twice. Once a response is sent, subsequent calls are no-ops.
44 // could call onRespond twice. Once a response is sent, subsequent calls are no-ops.
45+ // Keyed on permission.id: the host mounts this dialog against a conflating StateFlow,
46+ // so a null -> permissionB transition can collapse to just permissionB, reusing this
47+ // composable for a different permission. Without the key `responded` would stay true
48+ // and every button (and back-press) would be a no-op — an undismissable modal.
45− var responded by rememberSaveable { mutableStateOf(false) }
49+ var responded by rememberSaveable(permission.id) { mutableStateOf(false) }
46 val respond: (PermissionResponse) -> Unit = { response ->
50 val respond: (PermissionResponse) -> Unit = { response ->
47 if (!responded) {
51 if (!responded) {
48 responded = true
52 responded = true
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+6−2
@@ -196,8 +196,12 @@ private fun keywordsFor(lang: Language): Set<String> = when (lang) {
196}
196}
197
197
198private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): Boolean =
198private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): Boolean =
199+ // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
200+ // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
199− (c == '/' && i + 1 < line.length && line[i + 1] == '/') ||
201+ (c == '/' && lang == Language.C_FAMILY && i + 1 < line.length && line[i + 1] == '/') ||
202+ // `#` is a line comment only in Python and shell. It is NOT one in C-family
203+ // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
200− (c == '#' && lang != Language.MARKUP)
204+ (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL))
201
205
202private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
206private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
203
207
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+11−3
@@ -14,6 +14,8 @@ import androidx.compose.material3.Surface
14import androidx.compose.material3.Text
14import androidx.compose.material3.Text
15import androidx.compose.material3.TextButton
15import androidx.compose.material3.TextButton
16import androidx.compose.runtime.Composable
16import androidx.compose.runtime.Composable
17+import androidx.compose.runtime.remember
18+import androidx.compose.runtime.mutableStateOf
17import androidx.compose.ui.Alignment
19import androidx.compose.ui.Alignment
18import androidx.compose.ui.Modifier
20import androidx.compose.ui.Modifier
19import androidx.compose.ui.res.stringResource
21import androidx.compose.ui.res.stringResource
@@ -58,8 +60,14 @@ fun ConnectionBanner(
58 // AnimatedVisibility so the banner slides/fades in and out instead of appearing
60 // AnimatedVisibility so the banner slides/fades in and out instead of appearing
59 // and disappearing instantly — a state change that's especially jarring when the
61 // and disappearing instantly — a state change that's especially jarring when the
60 // connection flaps, and was called out as a rough edge in the UX audit.
62 // connection flaps, and was called out as a rough edge in the UX audit.
63+ // Retain the last non-null banner text so the exit fade-out has content to render.
64+ // The content lambda recomposes with the now-null `text` during the exit animation;
65+ // early-returning on null there would leave nothing to fade, so the banner would just
66+ // vanish instead of fading out.
67+ val lastText = remember { mutableStateOf(text) }
68+ if (text != null) lastText.value = text
61 AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
69 AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
62− if (text == null) return@AnimatedVisibility
70+ val shown = lastText.value ?: return@AnimatedVisibility
63 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
71 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
64 // transient connecting/reconnecting states by switching to the error palette,
72 // transient connecting/reconnecting states by switching to the error palette,
65 // so the banner conveys urgency without relying on text alone.
73 // so the banner conveys urgency without relying on text alone.
@@ -75,7 +83,7 @@ fun ConnectionBanner(
75 // Announce connection state changes to TalkBack users so they're
83 // Announce connection state changes to TalkBack users so they're
76 // aware the stream dropped/reconnecting without visual cues.
84 // aware the stream dropped/reconnecting without visual cues.
77 liveRegion = LiveRegionMode.Polite
85 liveRegion = LiveRegionMode.Polite
78− contentDescription = text
86+ contentDescription = shown
79 },
87 },
80 color = container,
88 color = container,
81 ) {
89 ) {
@@ -91,7 +99,7 @@ fun ConnectionBanner(
91 )
99 )
92 }
100 }
93 Text(
101 Text(
94− text,
102+ shown,
95 style = MaterialTheme.typography.labelMedium,
103 style = MaterialTheme.typography.labelMedium,
96 color = onContainer,
104 color = onContainer,
97 modifier = Modifier
105 modifier = Modifier
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+17−20
@@ -66,26 +66,23 @@ private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
66 return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
66 return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
67 }
67 }
68 val url = url ?: return null
68 val url = url ?: return null
69− return if (url.startsWith("http://") || url.startsWith("https://")) {
70− url
71− } else {
72− // Resolve the URL (relative or server-absolute like "/media/x.png") against the
69+ // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
73− // base, collapsing any ../ segments.
70+ // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
71+ // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
72+ // otherwise get the server's Basic auth attached and leak the credentials to that host.
74− val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
73+ val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
75− val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
74+ val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
76− val resolved = runCatching {
75+ val resolved = runCatching {
77− java.net.URI(base).resolve(url).normalize()
76+ java.net.URI(base).resolve(url).normalize()
78− }.getOrElse { return null }
77+ }.getOrElse { return null }
79− // Guard on same origin (scheme + host + port), not base-path prefix: the real
78+ // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
80− // risk is redirecting the request (with its Basic auth) to another host. A
79+ // sending the request (with its Basic auth) to another host. A server-absolute path
81− // server-absolute path resolves to a different base *path* but the same origin,
80+ // resolves to a different base *path* but the same origin, so it must still be allowed.
82− // so it must still be allowed — a prefix check wrongly rejected those.
83− val sameOrigin = resolved.host != null &&
81+ val sameOrigin = resolved.host != null &&
84− resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
82+ resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
85− resolved.host.equals(baseUri.host, ignoreCase = true) &&
83+ resolved.host.equals(baseUri.host, ignoreCase = true) &&
86− resolved.port == baseUri.port
84+ resolved.port == baseUri.port
87− if (sameOrigin) resolved.toString() else null
85+ return if (sameOrigin) resolved.toString() else null
88− }
89}
86}
90
87
91/**
88/**
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+5−3
@@ -58,9 +58,11 @@ fun relativeTime(epochMillis: Long?): String {
58 // rare timestamps (older than a week) and the numeric value is the primary signal.
58 // rare timestamps (older than a week) and the numeric value is the primary signal.
59 val weeks = days / 7
59 val weeks = days / 7
60 if (weeks < 5) return "${weeks}w"
60 if (weeks < 5) return "${weeks}w"
61− val months = days / 30
61+ // Switch to years only past a full 365 days. Gating on `months < 12` (with months =
62+ // days/30) instead misfires for 360–364 days: months == 12 fails the check and falls
63+ // through to years, rendering "1y" for a span that's under a year (~11.9 months).
62− if (months < 12) return "${months}mo"
64+ if (days < 365) return "${days / 30}mo"
63− val years = (days / 365).coerceAtLeast(1)
65+ val years = days / 365
64 return "${years}y"
66 return "${years}y"
65}
67}
66
68
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+6−1
@@ -132,7 +132,12 @@ fun FileBrowserScreen(
132 color = MaterialTheme.colorScheme.error,
132 color = MaterialTheme.colorScheme.error,
133 )
133 )
134 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
134 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
135+ // Retry the operation that actually failed: re-run the search
136+ // when one was active (open() would reset the query and drop the
137+ // user back into the directory listing), otherwise reload the dir.
135− androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
138+ androidx.compose.material3.TextButton(
139+ onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
140+ ) {
136 Text(stringResource(R.string.retry))
141 Text(stringResource(R.string.retry))
137 }
142 }
138 }
143 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+5−2
@@ -72,8 +72,11 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
72 loadStatus()
72 loadStatus()
73 // Re-open the current directory when a connection becomes available
73 // Re-open the current directory when a connection becomes available
74 // so the initial open("") (which may have run before auto-reconnect
74 // so the initial open("") (which may have run before auto-reconnect
75− // finished and found no connection) doesn't leave a stale error.
75+ // finished and found no connection) doesn't leave a stale error. But if
76+ // the user is mid-search, re-run the search instead — open() resets
77+ // query/results and would silently discard their in-progress search.
78+ if (_state.value.isSearching) setQuery(_state.value.query)
76− open(_state.value.path)
79+ else open(_state.value.path)
77 }
80 }
78 }
81 }
79 }
82 }
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+35−11
@@ -133,7 +133,7 @@ fun FileViewScreen(
133 loading = state.loading,
133 loading = state.loading,
134 content = rawText,
134 content = rawText,
135 wrap = wrap,
135 wrap = wrap,
136− showDiff = showDiff,
136+ inDiffMode = showToggle && showDiff,
137 onBack = onBack,
137 onBack = onBack,
138 onReload = { vm.reload() },
138 onReload = { vm.reload() },
139 onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
139 onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
@@ -159,6 +159,17 @@ fun FileViewScreen(
159 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
159 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
160 label = "fileViewState",
160 label = "fileViewState",
161 ) { s ->
161 ) { s ->
162+ // Derive match indices from THIS layer's content (s), not the outer/newest
163+ // state. During the crossfade the exiting layer still renders the old
164+ // content; passing the outer matchIndices (computed from the new content)
165+ // would highlight and scroll the wrong lines until the animation settles.
166+ val sMatchIndices = remember(s.content?.content, findQuery) {
167+ val q = findQuery.trim()
168+ if (q.isEmpty()) emptyList()
169+ else (s.content?.content.orEmpty()).split("\n")
170+ .mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
171+ .filter { it >= 0 }
172+ }
162 FileViewStateContent(
173 FileViewStateContent(
163 state = s,
174 state = s,
164 filename = filename,
175 filename = filename,
@@ -175,7 +186,7 @@ fun FileViewScreen(
175 findActive = findActive,
186 findActive = findActive,
176 findQuery = findQuery,
187 findQuery = findQuery,
177 onQueryChange = { findQuery = it; matchPos = 0 },
188 onQueryChange = { findQuery = it; matchPos = 0 },
178− matchIndices = matchIndices,
189+ matchIndices = sMatchIndices,
179 matchPos = matchPos,
190 matchPos = matchPos,
180 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
191 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
181 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
192 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
@@ -196,7 +207,7 @@ private fun FileViewTopBar(
196 loading: Boolean,
207 loading: Boolean,
197 content: String,
208 content: String,
198 wrap: Boolean,
209 wrap: Boolean,
199− showDiff: Boolean,
210+ inDiffMode: Boolean,
200 onBack: () -> Unit,
211 onBack: () -> Unit,
201 onReload: () -> Unit,
212 onReload: () -> Unit,
202 onToggleFind: () -> Unit,
213 onToggleFind: () -> Unit,
@@ -218,11 +229,12 @@ private fun FileViewTopBar(
218 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
229 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
219 }
230 }
220 if (content.isNotEmpty()) {
231 if (content.isNotEmpty()) {
221− // Find-in-file: toggles a search bar over the raw content. Disabled in
232+ // Find-in-file: toggles a search bar over the raw content. Disabled only when
222− // diff mode — the find bar searches the raw text and scrolls the line
233+ // the DiffView is actually on screen ([inDiffMode]) — the find bar searches the
223− // LazyColumn, neither of which is visible when the DiffView is shown, so
234+ // raw text and scrolls the line LazyColumn, neither of which is visible under a
235+ // diff, so find would silently no-op there. A plain file with no diff (the common
224− // find would silently no-op. The user can switch to Raw to find.
236+ // case) shows raw text, so find must stay enabled. The user can switch to Raw to find.
225− IconButton(onClick = onToggleFind, enabled = !showDiff) {
237+ IconButton(onClick = onToggleFind, enabled = !inDiffMode) {
226 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
238 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
227 }
239 }
228 // Wrap long lines instead of horizontal-scrolling, useful for prose.
240 // Wrap long lines instead of horizontal-scrolling, useful for prose.
@@ -327,9 +339,21 @@ private fun BoxScope.FileViewContentBody(
327 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
339 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
328 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
340 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
329 // cause the first lines of content to hide behind the overlay.
341 // cause the first lines of content to hide behind the overlay.
342+ // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
343+ // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
344+ // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
345+ // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
346+ // would keep the find bar — and therefore find-in-file — permanently hidden.
347+ val rawOnScreen = !(showToggle && showDiff)
330− val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && !showDiff) 1 else 0)
348+ val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
331 var overlayHeightPx by remember { mutableIntStateOf(0) }
349 var overlayHeightPx by remember { mutableIntStateOf(0) }
350+ // Only apply the measured inset while an overlay is actually composed. When overlayRows
351+ // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
352+ // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
353+ // a phantom top gap on the content below — e.g. after closing find on a plain file.
354+ val topInset = if (overlayRows > 0) {
332− val topInset = with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
355+ with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
356+ } else 0.dp
333 if (overlayRows > 0) {
357 if (overlayRows > 0) {
334 Column(
358 Column(
335 modifier = Modifier
359 modifier = Modifier
@@ -357,7 +381,7 @@ private fun BoxScope.FileViewContentBody(
357 )
381 )
358 }
382 }
359 }
383 }
360− if (findActive && !showDiff) {
384+ if (findActive && rawOnScreen) {
361 FindBar(
385 FindBar(
362 query = findQuery,
386 query = findQuery,
363 onQueryChange = onQueryChange,
387 onQueryChange = onQueryChange,
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+11−5
@@ -71,10 +71,13 @@ fun ServerEditScreen(
71 var passwordVisible by rememberSaveable { mutableStateOf(false) }
71 var passwordVisible by rememberSaveable { mutableStateOf(false) }
72
72
73 fun safeExit() {
73 fun safeExit() {
74+ when {
75+ // A save is in flight: ignore the back gesture (matching the BackHandler guard
76+ // below). Popping here would cancel the save's viewModelScope coroutine and
77+ // silently abandon the write; the VM calls onDone itself once the save lands.
74− if (state.isDirty && !state.saving) {
78+ state.saving -> Unit
75− showDiscardConfirm = true
79+ state.isDirty -> showDiscardConfirm = true
76− } else {
77− onDone()
80+ else -> onDone()
78 }
81 }
79 }
82 }
80
83
@@ -319,7 +322,10 @@ private fun AuthFields(
319 )
322 )
320 OutlinedButton(
323 OutlinedButton(
321 onClick = onTestCredentials,
324 onClick = onTestCredentials,
325+ // Require at least one credential field: testCredentials() no-ops when both are
326+ // blank, so without this the button is tappable but silently does nothing.
322− enabled = state.canSave && !state.testingCredentials && !state.saving,
327+ enabled = state.canSave && !state.testingCredentials && !state.saving &&
328+ (state.username.isNotBlank() || state.password.isNotBlank()),
323 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
329 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
324 ) {
330 ) {
325 if (state.testingCredentials) {
331 if (state.testingCredentials) {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+13−2
@@ -144,7 +144,15 @@ class ServerEditViewModel(
144 password = source.password.orEmpty(),
144 password = source.password.orEmpty(),
145 loaded = true,
145 loaded = true,
146 authFieldsVisible = source.hasAuth,
146 authFieldsVisible = source.hasAuth,
147+ // Snapshot the seeded (copy) values so an untouched duplicate isn't
148+ // immediately dirty — an empty initial would make backing out of a
149+ // freshly opened duplicate always trigger the discard-changes dialog.
147− initial = InitialProfile("", "", "", ""),
150+ initial = InitialProfile(
151+ label = dupLabel.trim(),
152+ baseUrl = source.baseUrl.trim(),
153+ username = source.username.orEmpty().trim(),
154+ password = source.password.orEmpty().trim(),
155+ ),
148 )
156 )
149 return@launch
157 return@launch
150 }
158 }
@@ -162,7 +170,10 @@ class ServerEditViewModel(
162 }
170 }
163
171
164 fun update(transform: (ServerEditState) -> ServerEditState) {
172 fun update(transform: (ServerEditState) -> ServerEditState) {
173+ // Clear both the inline credential note and the bottom-of-form error when any field
174+ // changes: once the user starts editing (e.g. correcting a rejected password), a stale
175+ // "Credentials rejected" / test-failure message no longer describes the current input.
165− _state.update { transform(it).copy(credentialsResult = null) }
176+ _state.update { transform(it).copy(credentialsResult = null, error = null) }
166 }
177 }
167
178
168 fun probe() {
179 fun probe() {
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+19−25
@@ -8,7 +8,6 @@ import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.R
8import soy.iko.opencode.R
9import soy.iko.opencode.util.runCatchingCancellable
9import soy.iko.opencode.util.runCatchingCancellable
10import kotlinx.coroutines.channels.BufferOverflow
10import kotlinx.coroutines.channels.BufferOverflow
11−import kotlinx.coroutines.delay
12import kotlinx.coroutines.flow.MutableSharedFlow
11import kotlinx.coroutines.flow.MutableSharedFlow
13import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.MutableStateFlow
14import kotlinx.coroutines.flow.SharedFlow
13import kotlinx.coroutines.flow.SharedFlow
@@ -60,12 +59,6 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
60 )
59 )
61 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
60 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
62
61
63− /** Profile awaiting the undo window to expire before its REST delete fires. */
64− // Keyed by profile id, not a single field: several deletes can overlap within the
65− // undo window, and a single field would let the second overwrite the first — leaving
66− // the first profile in the store forever while it's hidden from the list.
67− private val pendingDeletes = mutableMapOf<String, ServerProfile>()
68−
69 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
62 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
70 if (_connecting.value != null) return
63 if (_connecting.value != null) return
71 _connecting.value = profile.id
64 _connecting.value = profile.id
@@ -104,7 +97,6 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
104 * hidden by the optimistic removal, and the undo re-shows it without reconnecting.
97 * hidden by the optimistic removal, and the undo re-shows it without reconnecting.
105 */
98 */
106 fun delete(profile: ServerProfile) {
99 fun delete(profile: ServerProfile) {
107− pendingDeletes[profile.id] = profile
108 // Optimistically hide the row so the UI feels instant; undo re-shows it.
100 // Optimistically hide the row so the UI feels instant; undo re-shows it.
109 _hiddenIds.update { it + profile.id }
101 _hiddenIds.update { it + profile.id }
110 if (container.activeConnection.value?.profile?.id == profile.id) {
102 if (container.activeConnection.value?.profile?.id == profile.id) {
@@ -113,27 +105,29 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
113 viewModelScope.launch { container.disconnect() }
105 viewModelScope.launch { container.disconnect() }
114 }
106 }
115 _undoEvents.tryEmit(profile.id)
107 _undoEvents.tryEmit(profile.id)
116− viewModelScope.launch {
108+ // Run the deferred delete on the container's process-lived scope, not viewModelScope:
109+ // if the user navigates away from the server list within the undo window, a
110+ // viewModelScope job would be cancelled and the profile would never be deleted
111+ // (reappearing on return). The container owns the timer and the undo cancellation.
112+ container.scheduleProfileDelete(
113+ id = profile.id,
117− delay(NetworkConfig.undoServerDeleteDelayMs)
114+ delayMs = NetworkConfig.undoServerDeleteDelayMs,
118− if (pendingDeletes.remove(profile.id) != null) {
115+ onDeleted = {
119− runCatchingCancellable { container.profileStore.delete(profile.id) }
120− .onSuccess {
121− // Drop the optimistic hide only after the store has emitted the
116+ // Drop the optimistic hide only after the store has emitted the
122− // removal, so the row doesn't flash back before the list updates.
117+ // removal, so the row doesn't flash back before the list updates.
123− _hiddenIds.update { it - profile.id }
118+ _hiddenIds.update { it - profile.id }
124− }
119+ },
125− .onFailure {
120+ onError = {
126− // Delete failed: re-show the row so it isn't hidden forever.
121+ // Delete failed: re-show the row so it isn't hidden forever.
127− _hiddenIds.update { it - profile.id }
122+ _hiddenIds.update { it - profile.id }
128− _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null))
123+ _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null))
129− }
130− }
124+ },
131− }
125+ )
132 }
126 }
133
127
134 /** Cancel a pending delete (Undo snackbar action) and re-show the hidden row. */
128 /** Cancel a pending delete (Undo snackbar action) and re-show the hidden row. */
135 fun undoDelete(profileId: String) {
129 fun undoDelete(profileId: String) {
136− pendingDeletes.remove(profileId)
130+ container.cancelProfileDelete(profileId)
137 _hiddenIds.update { it - profileId }
131 _hiddenIds.update { it - profileId }
138 }
132 }
139}
133}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+77−34
@@ -135,11 +135,14 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
135 )
135 )
136 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
136 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
137
137
138− /** Session awaiting the undo window to expire before its REST delete fires. */
138+ /** Session awaiting the undo window to expire before its REST delete fires. Holds the
139+ * Session so Undo can restore it. */
139 // Keyed by session id, not a single field: the undo window lets several deletes be
140 // Keyed by session id, not a single field: the undo window lets several deletes be
140 // in flight at once, and a single field would let a second delete overwrite the
141 // in flight at once, and a single field would let a second delete overwrite the
141 // first — dropping the first's server-side delete while it stays removed from the UI.
142 // first — dropping the first's server-side delete while it stays removed from the UI.
143+ // Concurrent because the delete timer's completion callbacks run on the container's
144+ // process-lived scope while the SSE collector mutates this on viewModelScope.
142− private val pendingDeletes = mutableMapOf<String, Session>()
145+ private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Session>()
143
146
144 private var previewJob: Job? = null
147 private var previewJob: Job? = null
145 private var refreshJob: Job? = null
148 private var refreshJob: Job? = null
@@ -183,6 +186,14 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
183 }
186 }
184 sessionUpdateJob?.cancel()
187 sessionUpdateJob?.cancel()
185 pendingSessionUpdates.clear()
188 pendingSessionUpdates.clear()
189+ // Cancel any deferred deletes still pending from the previous connection.
190+ // Their captured connection is now closed, so they can only fail; letting
191+ // them run would fire onError and re-inject a session belonging to the old
192+ // server into this (new) server's list. Cancelling drops them cleanly — the
193+ // session simply survives on its original server (unavoidable once we've
194+ // switched away) rather than appearing as a foreign row here.
195+ pendingDeletes.keys.toList().forEach { container.cancelSessionDelete(it) }
196+ pendingDeletes.clear()
186 // Reload the session list when a connection becomes available so the
197 // Reload the session list when a connection becomes available so the
187 // initial load (which may have run before auto-reconnect finished and
198 // initial load (which may have run before auto-reconnect finished and
188 // found no connection) doesn't leave a stale "not connected" error.
199 // found no connection) doesn't leave a stale "not connected" error.
@@ -212,6 +223,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
212 // Drop any buffered update for this id so a pending flush
223 // Drop any buffered update for this id so a pending flush
213 // can't re-add the just-deleted session to the list.
224 // can't re-add the just-deleted session to the list.
214 pendingSessionUpdates.remove(id)
225 pendingSessionUpdates.remove(id)
226+ // The session is gone server-side; cancel any pending local
227+ // delete so its deferred REST call doesn't later 404 and
228+ // trigger the failure-restore path that resurrects the row.
229+ pendingDeletes.remove(id)
215 container.draftStore.remove(id)
230 container.draftStore.remove(id)
216 _state.update { s ->
231 _state.update { s ->
217 s.copy(
232 s.copy(
@@ -243,19 +258,22 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
243 // would otherwise be dropped. The key/value remove overload also leaves a newer
258 // would otherwise be dropped. The key/value remove overload also leaves a newer
244 // value for the same key in place to be flushed on the next pass.
259 // value for the same key in place to be flushed on the next pass.
245 batch.forEach { (k, v) -> pendingSessionUpdates.remove(k, v) }
260 batch.forEach { (k, v) -> pendingSessionUpdates.remove(k, v) }
261+ val changedIds = mutableSetOf<String>()
246 _state.update { s ->
262 _state.update { s ->
263+ // Reset per-attempt: MutableStateFlow.update may re-run this lambda on CAS
264+ // contention, and we only want the ids that changed in the winning attempt.
265+ changedIds.clear()
247 val byId = s.sessions.associateBy { it.id }.toMutableMap()
266 val byId = s.sessions.associateBy { it.id }.toMutableMap()
248− var anyChanged = false
249 batch.forEach { (id, session) ->
267 batch.forEach { (id, session) ->
250 // Final guard: never re-add a session that's pending deletion (its undo
268 // Final guard: never re-add a session that's pending deletion (its undo
251 // window is still open), even if an update slipped into this batch.
269 // window is still open), even if an update slipped into this batch.
252− if (id in pendingDeletes) return@forEach
270+ if (pendingDeletes.containsKey(id)) return@forEach
253− if (byId[id] != session) { byId[id] = session; anyChanged = true }
271+ if (byId[id] != session) { byId[id] = session; changedIds += id }
254 }
272 }
255 // No-op update: skip the rebuild entirely so the StateFlow doesn't emit a
273 // No-op update: skip the rebuild entirely so the StateFlow doesn't emit a
256 // new state (and trigger recomposition) when the batch held no real changes
274 // new state (and trigger recomposition) when the batch held no real changes
257 // (e.g. a duplicate SessionUpdated for an unchanged session).
275 // (e.g. a duplicate SessionUpdated for an unchanged session).
258− if (!anyChanged) return@update s
276+ if (changedIds.isEmpty()) return@update s
259 val sorted = byId.values.sortedByMode(s.sortMode)
277 val sorted = byId.values.sortedByMode(s.sortMode)
260 // If the new ordering matches the existing one (e.g. only the already-newest
278 // If the new ordering matches the existing one (e.g. only the already-newest
261 // session updated its timestamp), reuse the same list instance so downstream
279 // session updated its timestamp), reuse the same list instance so downstream
@@ -263,7 +281,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
263 val sessions = if (sorted == s.sessions) s.sessions else sorted
281 val sessions = if (sorted == s.sessions) s.sessions else sorted
264 s.copy(sessions = sessions)
282 s.copy(sessions = sessions)
265 }
283 }
284+ // Only refetch previews for sessions that actually changed — a duplicate
285+ // SessionUpdated for an unchanged session shouldn't trigger a full-history download.
266− batch.keys.forEach { loadPreview(it) }
286+ changedIds.forEach { loadPreview(it) }
267 }
287 }
268
288
269 fun setQuery(query: String) {
289 fun setQuery(query: String) {
@@ -290,7 +310,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
290 return
310 return
291 }
311 }
292 _serverLabel.value = conn.profile.displayLabel
312 _serverLabel.value = conn.profile.displayLabel
313+ // Only show the full-screen spinner on the initial load (empty list). For a
314+ // pull-to-refresh or SSE-reconnect refresh with an existing list, the dedicated
315+ // PullToRefreshBox indicator (_refreshing) covers it — flipping `loading` here
316+ // would blank the populated list behind a centered spinner.
293− _state.update { it.copy(loading = true, error = null) }
317+ _state.update { it.copy(loading = it.sessions.isEmpty(), error = null) }
294 _refreshing.value = true
318 _refreshing.value = true
295 refreshJob?.cancel()
319 refreshJob?.cancel()
296 refreshJob = viewModelScope.launch {
320 refreshJob = viewModelScope.launch {
@@ -298,7 +322,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
298 try {
322 try {
299 runCatchingCancellable { conn.repository.listSessions() }
323 runCatchingCancellable { conn.repository.listSessions() }
300 .onSuccess { list ->
324 .onSuccess { list ->
325+ // Drop sessions that were optimistically deleted but whose deferred
326+ // REST delete hasn't fired yet — the server still returns them, and
327+ // re-adding them here would resurrect a just-hidden row (and later
328+ // collide on the LazyColumn id key when undo/restore re-appends it).
301− val sorted = list.sortedByMode(_state.value.sortMode)
329+ val sorted = list.filterNot { pendingDeletes.containsKey(it.id) }.sortedByMode(_state.value.sortMode)
302 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
330 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
303 loadPreviews(sorted)
331 loadPreviews(sorted)
304 }
332 }
@@ -414,33 +442,44 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
414 // undo window can't re-add the row we just optimistically hid.
442 // undo window can't re-add the row we just optimistically hid.
415 pendingSessionUpdates.remove(session.id)
443 pendingSessionUpdates.remove(session.id)
416 _undoEvents.tryEmit(session.id)
444 _undoEvents.tryEmit(session.id)
417− viewModelScope.launch {
445+ // Run the deferred delete on the container's process-lived scope, not viewModelScope:
418− delay(NetworkConfig.undoDeleteDelayMs)
446+ // if this VM is cleared during the undo window (e.g. a disconnect pops the session
419− // If undo ran, this id was already removed — remove() returning null means
447+ // list), a viewModelScope job would be cancelled and the server-side delete would
420− // "don't delete". Removing by id also can't be clobbered by a later delete.
448+ // never fire — the row was already optimistically hidden, so the session would
421− if (pendingDeletes.remove(session.id) != null) {
449+ // silently resurrect on the next refresh. The container also clears the stored draft.
422− val conn = container.activeConnection.value ?: return@launch
450+ container.scheduleSessionDelete(
423− runCatchingCancellable { conn.repository.deleteSession(session.id) }
451+ id = session.id,
424− .onSuccess {
452+ delayMs = NetworkConfig.undoDeleteDelayMs,
453+ onDeleted = {
425− container.draftStore.remove(session.id)
454+ pendingDeletes.remove(session.id)
426− refresh()
455+ refresh()
427− }
456+ },
428− .onFailure {
457+ onError = {
429− // Restore the session to the list since the delete failed.
458+ // Restore the session since the delete failed (or there was no connection to
459+ // delete on). Guard against a concurrent refresh having already re-added it so
460+ // we never produce two rows with the same id.
461+ pendingDeletes.remove(session.id)
462+ _state.update { s ->
430− _state.update { s -> s.copy(sessions = (s.sessions + session).sortedByMode(s.sortMode)) }
463+ s.copy(sessions = (s.sessions.filterNot { it.id == session.id } + session).sortedByMode(s.sortMode))
464+ }
431− _transientErrors.tryEmit(container.friendlyError(it))
465+ _transientErrors.tryEmit(container.friendlyError(it))
432− }
433− }
466+ },
434− }
467+ )
435 }
468 }
436
469
437 /** Cancel a pending delete (Undo snackbar action) and restore the session. */
470 /** Cancel a pending delete (Undo snackbar action) and restore the session. */
438 fun undoDelete(sessionId: String) {
471 fun undoDelete(sessionId: String) {
439− val pending = pendingDeletes.remove(sessionId)
472+ // Cancel the deferred container-scoped delete first. If it already fired (cancel
440− if (pending != null) {
473+ // returns false), the delete is committing/committed — drop the token and don't
474+ // restore, so we never re-add a session that's gone on the server.
475+ if (!container.cancelSessionDelete(sessionId)) {
476+ pendingDeletes.remove(sessionId)
477+ return
478+ }
479+ val pending = pendingDeletes.remove(sessionId) ?: return
441− _state.update { s ->
480+ _state.update { s ->
481+ // Dedup by id in case a refresh during the undo window already re-added it.
442− s.copy(sessions = (s.sessions + pending).sortedByMode(s.sortMode))
482+ s.copy(sessions = (s.sessions.filterNot { it.id == pending.id } + pending).sortedByMode(s.sortMode))
443− }
444 }
483 }
445 }
484 }
446
485
@@ -492,12 +531,16 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
492 savedUnread.forEach { (id, count) -> container.restoreUnread(id, count) }
531 savedUnread.forEach { (id, count) -> container.restoreUnread(id, count) }
493 if (restored.isSuccess) {
532 if (restored.isSuccess) {
494 // Clear stale sessions from the old server; the SSE observer
533 // Clear stale sessions from the old server; the SSE observer
495− // will reload from the restored connection via refresh().
534+ // will reload from the restored connection via refresh(). Preserve
535+ // the user's sort mode and search query — the switch failed and they
536+ // were bounced back to the same server, so resetting those (via a
537+ // fresh SessionListState) would be an unexpected loss on an error path.
496 _state.update {
538 _state.update {
497− SessionListState(
539+ it.copy(
498 sessions = emptyList(),
540 sessions = emptyList(),
499 previews = emptyMap(),
541 previews = emptyMap(),
500 loading = false,
542 loading = false,
543+ error = null,
501 )
544 )
502 }
545 }
503 // Surface the switch failure as a transient snackbar — refresh()
546 // Surface the switch failure as a transient snackbar — refresh()
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+54−32
@@ -51,7 +51,11 @@ import androidx.compose.runtime.rememberCoroutineScope
51import androidx.compose.runtime.saveable.rememberSaveable
51import androidx.compose.runtime.saveable.rememberSaveable
52import androidx.compose.runtime.setValue
52import androidx.compose.runtime.setValue
53import kotlinx.coroutines.Dispatchers
53import kotlinx.coroutines.Dispatchers
54+import kotlinx.coroutines.channels.BufferOverflow
55+import kotlinx.coroutines.coroutineScope
54import kotlinx.coroutines.delay
56import kotlinx.coroutines.delay
57+import kotlinx.coroutines.flow.MutableSharedFlow
58+import kotlinx.coroutines.flow.collectLatest
55import kotlinx.coroutines.launch
59import kotlinx.coroutines.launch
56import kotlinx.coroutines.withContext
60import kotlinx.coroutines.withContext
57import soy.iko.opencode.util.runCatchingCancellable
61import soy.iko.opencode.util.runCatchingCancellable
@@ -96,6 +100,35 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
96 val shareSubject = stringResource(R.string.crash_report_share_subject)
100 val shareSubject = stringResource(R.string.crash_report_share_subject)
97 val timeTick = rememberRelativeTimeTick()
101 val timeTick = rememberRelativeTimeTick()
98
102
103+ // Report-delete undo events, shown via a single collectLatest collector rather than a
104+ // per-confirm launch. A serialized showSnackbar would queue each report's undo behind
105+ // the previous one's full window, so under rapid deletes a later report's delete timer
106+ // (started at confirm time) fires before its snackbar is ever shown — a dead Undo
107+ // button. collectLatest cancels the current snackbar and shows the newest immediately,
108+ // keeping each Undo window aligned with its own timer. Mirrors ServerListScreen.
109+ val reportDeleteEvents = remember {
110+ MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
111+ }
112+ LaunchedEffect(Unit) {
113+ reportDeleteEvents.collectLatest { name ->
114+ coroutineScope {
115+ val dismisser = launch {
116+ delay(NetworkConfig.undoReportDeleteDelayMs)
117+ snackbar.currentSnackbarData?.dismiss()
118+ }
119+ val result = snackbar.showSnackbar(
120+ message = reportDeletedLabel,
121+ actionLabel = undoLabel,
122+ duration = SnackbarDuration.Indefinite,
123+ )
124+ dismisser.cancel()
125+ if (result == SnackbarResult.ActionPerformed) {
126+ logger.cancelScheduledDelete(name)
127+ }
128+ }
129+ }
130+ }
131+
99 Scaffold(
132 Scaffold(
100 topBar = {
133 topBar = {
101 TopAppBar(
134 TopAppBar(
@@ -244,18 +277,24 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
244 },
277 },
245 dismissButton = {
278 dismissButton = {
246 Row {
279 Row {
247− TextButton(onClick = {
280+ TextButton(
281+ // Disable Share until the report has loaded — otherwise tapping it
282+ // while the async read is still in flight (content == null) shares an
283+ // empty body instead of the report.
284+ enabled = content != null,
285+ onClick = {
248− val send = Intent(Intent.ACTION_SEND).apply {
286+ val send = Intent(Intent.ACTION_SEND).apply {
249− type = "text/plain"
287+ type = "text/plain"
250− putExtra(Intent.EXTRA_SUBJECT, shareSubject)
288+ putExtra(Intent.EXTRA_SUBJECT, shareSubject)
251− putExtra(Intent.EXTRA_TEXT, content.orEmpty())
289+ putExtra(Intent.EXTRA_TEXT, content.orEmpty())
252− }
253− runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
254− .onFailure {
255− Log.w("Diagnostics", "Failed to share crash report", it)
256− showToast(context, context.getString(R.string.no_share_app))
257 }
290 }
291+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
292+ .onFailure {
293+ Log.w("Diagnostics", "Failed to share crash report", it)
294+ showToast(context, context.getString(R.string.no_share_app))
295+ }
296+ },
258− }) { Text(stringResource(R.string.share)) }
297+ ) { Text(stringResource(R.string.share)) }
259 Spacer(Modifier.size(8.dp))
298 Spacer(Modifier.size(8.dp))
260 TextButton(onClick = {
299 TextButton(onClick = {
261 // Confirm before deleting a single report, matching clear-all.
300 // Confirm before deleting a single report, matching clear-all.
@@ -308,28 +347,11 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
308 viewing = null
347 viewing = null
309 // Defer the actual delete so the Undo snackbar can cancel it. The
348 // Defer the actual delete so the Undo snackbar can cancel it. The
310 // deletion is owned by the CrashLogger's own scope, so it commits even
349 // deletion is owned by the CrashLogger's own scope, so it commits even
311− // if the user navigates away during the undo window (the screen's
350+ // if the user navigates away during the undo window. The Undo snackbar
312− // shareScope would otherwise cancel it, silently dropping the delete).
351+ // is shown by the single collectLatest collector above (not a per-confirm
352+ // launch) so rapid consecutive deletes don't produce a dead Undo button.
313 logger.scheduleDelete(name, NetworkConfig.undoReportDeleteDelayMs)
353 logger.scheduleDelete(name, NetworkConfig.undoReportDeleteDelayMs)
314− shareScope.launch {
354+ reportDeleteEvents.tryEmit(name)
315− // Indefinite + a matching timed dismiss keeps the Undo button
316− // visible for exactly the undo window and no longer: a fixed
317− // SnackbarDuration.Long (~10s) outlasted the 5s window, leaving
318− // the button on screen but dead for its second half.
319− val dismisser = launch {
320− delay(NetworkConfig.undoReportDeleteDelayMs)
321− snackbar.currentSnackbarData?.dismiss()
322− }
323− val result = snackbar.showSnackbar(
324− message = reportDeletedLabel,
325− actionLabel = undoLabel,
326− duration = SnackbarDuration.Indefinite,
327− )
328− dismisser.cancel()
329− if (result == SnackbarResult.ActionPerformed) {
330− logger.cancelScheduledDelete(name)
331− }
332− }
333 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
355 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
334 },
356 },
335 dismissButton = {
357 dismissButton = {
app/src/main/res/values/strings.xml+1
@@ -259,4 +259,5 @@
259 <string name="run_command_title">Run /%1$s?</string>
259 <string name="run_command_title">Run /%1$s?</string>
260 <string name="run_command_text">Run the command “%1$s”?</string>
260 <string name="run_command_text">Run the command “%1$s”?</string>
261 <string name="run">Run</string>
261 <string name="run">Run</string>
262+ <string name="command_busy">Wait for the current response to finish before running a command.</string>
262</resources>
263</resources>
app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+5−5
@@ -135,7 +135,7 @@ class ChatViewModelTest {
135 assertEquals("hello", vm.draft.value)
135 assertEquals("hello", vm.draft.value)
136 assertEquals("hello", vm.failedDraft.value)
136 assertEquals("hello", vm.failedDraft.value)
137 assertFalse(vm.running.value)
137 assertFalse(vm.running.value)
138− val errors = mutableListOf<String>()
138+ val errors = mutableListOf<ChatError>()
139 val collectJob = launch { vm.errorEvents.toList(errors) }
139 val collectJob = launch { vm.errorEvents.toList(errors) }
140 assertTrue(vm.send("hello"))
140 assertTrue(vm.send("hello"))
141 testScheduler.advanceUntilIdle()
141 testScheduler.advanceUntilIdle()
@@ -252,7 +252,7 @@ class ChatViewModelTest {
252 testScope.testScheduler.advanceUntilIdle()
252 testScope.testScheduler.advanceUntilIdle()
253 assertEquals("p1", vm.pendingPermission.value?.id)
253 assertEquals("p1", vm.pendingPermission.value?.id)
254
254
255− val errors = mutableListOf<String>()
255+ val errors = mutableListOf<ChatError>()
256 val collectJob = launch { vm.errorEvents.toList(errors) }
256 val collectJob = launch { vm.errorEvents.toList(errors) }
257 vm.respondPermission(perm, PermissionResponse.ONCE)
257 vm.respondPermission(perm, PermissionResponse.ONCE)
258 assertNull(vm.pendingPermission.value)
258 assertNull(vm.pendingPermission.value)
@@ -281,7 +281,7 @@ class ChatViewModelTest {
281 val events = FakeEventStreamClient()
281 val events = FakeEventStreamClient()
282 val container = makeContainer(events = events)
282 val container = makeContainer(events = events)
283 val vm = makeVm(container)
283 val vm = makeVm(container)
284− val errors = mutableListOf<String>()
284+ val errors = mutableListOf<ChatError>()
285 val collectJob = launch { vm.errorEvents.toList(errors) }
285 val collectJob = launch { vm.errorEvents.toList(errors) }
286 assertTrue(vm.send("hello"))
286 assertTrue(vm.send("hello"))
287 testScope.testScheduler.advanceUntilIdle()
287 testScope.testScheduler.advanceUntilIdle()
@@ -485,7 +485,7 @@ class ChatViewModelTest {
485 val container = makeContainer(api = api, events = events, repo = repo)
485 val container = makeContainer(api = api, events = events, repo = repo)
486 val vm = makeVm(container)
486 val vm = makeVm(container)
487
487
488− val errors = mutableListOf<String>()
488+ val errors = mutableListOf<ChatError>()
489 val collectJob = launch { vm.errorEvents.toList(errors) }
489 val collectJob = launch { vm.errorEvents.toList(errors) }
490
490
491 // Two rapid sends that both fail — both should produce independent error
491 // Two rapid sends that both fail — both should produce independent error
@@ -511,7 +511,7 @@ class ChatViewModelTest {
511 repo.observeMessagesOverride = messagesFlow
511 repo.observeMessagesOverride = messagesFlow
512 val container = makeContainer(api = api, events = events, repo = repo)
512 val container = makeContainer(api = api, events = events, repo = repo)
513 val vm = makeVm(container)
513 val vm = makeVm(container)
514− val errors = mutableListOf<String>()
514+ val errors = mutableListOf<ChatError>()
515 val collectJob = launch { vm.errorEvents.toList(errors) }
515 val collectJob = launch { vm.errorEvents.toList(errors) }
516 // Subscribe to messages so the flow's retryWhen is active.
516 // Subscribe to messages so the flow's retryWhen is active.
517 val collector = launch { vm.messages.collect { /* keep subscribed */ } }
517 val collector = launch { vm.messages.collect { /* keep subscribed */ } }