git.iko.soy/opencode-android
on master help sign in
Fix 5 P0 bugs (deep-link session re-open after process restore, SSE backoff never escalating on immediate drop, SSE auth failure breaking the flow instead of awaiting reconnect, stale message info after reconnect, unread badge race for on-screen session) and 11 P1/P2 fixes (case-insensitive URL scheme, crash logger deferred-delete job leak, code highlighter number scanning, diff header misclassification, file view match index out of range, profile load condition, undo snackbar timing on 2 screens, optimistic server row hiding, session update flush race, encodeDefaults)
commitcec330383b84bd30613708328317b9c8ab2510c4 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 15:53 (3 months ago) parente4dceb6
18 files changed+251−62
app/src/main/java/soy/iko/opencode/MainActivity.kt+17−2 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+19−4 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+5−2 app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.kt+1 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+4−1 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+27−6 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+13−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+16 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+18−3 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+8−2 app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+26−9 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+17−5 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+5 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+2−2 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+23−10 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+24−3 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+21−10 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+5−1
app/src/main/java/soy/iko/opencode/MainActivity.kt+17−2
@@ -28,12 +28,16 @@ class MainActivity : ComponentActivity() {
2828 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
2929
3030 private var shareIntentHandled = false
31+ private var openSessionHandled = false
3132
3233 override fun onCreate(savedInstanceState: Bundle?) {
3334 val splash = installSplashScreen()
3435 super.onCreate(savedInstanceState)
3536 enableEdgeToEdge()
36− savedInstanceState?.let { shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false) }
37+ savedInstanceState?.let {
38+ shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
39+ openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
40+ }
3741 val container = (application as OpencodeApp).container
3842 handleIntent(intent)
3943 if (savedInstanceState == null) maybeRequestNotificationPermission()
@@ -65,6 +69,7 @@ class MainActivity : ComponentActivity() {
6569 super.onNewIntent(intent)
6670 setIntent(intent)
6771 shareIntentHandled = false
72+ openSessionHandled = false
6873 handleIntent(intent)
6974 }
7075
@@ -90,7 +95,15 @@ class MainActivity : ComponentActivity() {
9095 // Validate: session ids are opaque server-generated identifiers. Reject anything
9196 // with path separators or other traversal/control characters so a malicious deep
9297 // link can't inject path components into the REST URL path.
93− id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let { container.requestOpenSession(it) }
98+ // Guard against re-firing the retained ACTION_VIEW intent after process-death
99+ // restore: onCreate calls handleIntent again with the original intent, so without
100+ // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
101+ if (!openSessionHandled) {
102+ id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
103+ container.requestOpenSession(it)
104+ openSessionHandled = true
105+ }
106+ }
94107 }
95108
96109 companion object {
@@ -101,11 +114,13 @@ class MainActivity : ComponentActivity() {
101114 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
102115
103116 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
117+ private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
104118 }
105119
106120 override fun onSaveInstanceState(outState: Bundle) {
107121 super.onSaveInstanceState(outState)
108122 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
123+ outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
109124 }
110125
111126 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+19−4
@@ -147,7 +147,6 @@ open class EventStreamClient(
147147 }
148148 },
149149 ) {
150− backoffMs = initialBackoffMs
151150 // The SSE block is open — the connection is established. Mark as
152151 // Connected immediately so downstream re-seed logic fires even when
153152 // the server only sends keep-alive comments (no data events).
@@ -161,8 +160,19 @@ open class EventStreamClient(
161160 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
162161 // element can be raced against an idle timeout via select.
163162 val events = incoming.produceIn(scope)
163+ // Reset the backoff only once real data actually arrives, not on
164+ // block entry: a server that accepts the connection then immediately
165+ // drops it would otherwise loop forever at the initial backoff and
166+ // never escalate the exponential ramp.
167+ var receivedAny = false
164168 try {
165− readSseEvents(scope, events) { send(it) }
169+ readSseEvents(scope, events) {
170+ if (!receivedAny) {
171+ receivedAny = true
172+ backoffMs = initialBackoffMs
173+ }
174+ send(it)
175+ }
166176 } finally {
167177 events.cancel()
168178 }
@@ -177,9 +187,14 @@ open class EventStreamClient(
177187 // Credentials are wrong — retrying won't help. Log a scrubbed
178188 // summary (class + status) instead of the full exception, whose
179189 // message carries the request URL and may include auth or paths.
180− Log.w("EventStream", "SSE auth failed (401/403), stopping retries: ${safeExceptionSummary(e)}")
190+ Log.w("EventStream", "SSE auth failed (401/403), awaiting explicit reconnect: ${safeExceptionSummary(e)}")
181191 _state.value = ConnectionState.Failed
182− break
192+ if (!isActive) break
193+ // Don't complete the flow: a subscriber may fix the profile and call
194+ // triggerReconnect(). Suspend until an explicit reconnect signal
195+ // arrives (no backoff delay), then re-attempt the connection.
196+ reconnectSignal.receive()
197+ continue
183198 } else if (isActive) {
184199 Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
185200 }
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+5−2
@@ -53,7 +53,7 @@ object HttpClientFactory {
5353 }
5454
5555 val normalizedUrl = normalizeBaseUrl(profile.baseUrl)
56− val isHttps = normalizedUrl.startsWith("https://")
56+ val isHttps = normalizedUrl.lowercase().startsWith("https://")
5757
5858 if (profile.hasAuth) {
5959 if (isHttps) {
@@ -101,7 +101,10 @@ object HttpClientFactory {
101101 * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
102102 fun normalizeBaseUrl(raw: String): String {
103103 val trimmed = raw.trim()
104− val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
104+ // Compare the scheme prefix case-insensitively (a lowercased copy) while
105+ // preserving the original casing of the rest of the URL in the output.
106+ val lowerTrimmed = trimmed.lowercase()
107+ val withScheme = if (lowerTrimmed.startsWith("http://") || lowerTrimmed.startsWith("https://")) {
105108 trimmed
106109 } else {
107110 "https://$trimmed"
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.kt+1
@@ -25,6 +25,7 @@ val OpencodeJson: Json = Json {
2525 ignoreUnknownKeys = true
2626 isLenient = true
2727 coerceInputValues = true
28+ encodeDefaults = true
2829 explicitNulls = false
2930 classDiscriminator = "type"
3031 serializersModule = SerializersModule {
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+4−1
@@ -106,7 +106,10 @@ class CrashLogger private constructor(private val appContext: Context) {
106106 if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
107107 file.delete()
108108 }
109− pendingDeletes.remove(fileName)
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) }
110113 refresh()
111114 }
112115 pendingDeletes.put(fileName, job)?.cancel()
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+27−6
@@ -171,9 +171,21 @@ internal class MessageStore {
171171 * (e.g. a tool going running -> completed) stayed stale until app restart. */
172172 private val streamedSincePivot = mutableSetOf<String>()
173173
174− /** Mark the point a reconnect re-seed's REST fetch begins: parts streamed from here on
175− * are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
176− fun beginReseed() { streamedSincePivot.clear() }
174+ /** Message ids whose `info` was updated via a live SSE [reduce] since the last reseed
175+ * pivot ([beginReseed]) or seed. Parallels [streamedSincePivot] but for message info:
176+ * on a reconnect re-seed these are the messages whose info changed *during* the REST
177+ * fetch, so the in-memory info is newer than the snapshot and must win; every other
178+ * message's info is taken from the authoritative REST snapshot. Without this the merge
179+ * kept the in-memory info, so a run that finished server-side during the disconnect
180+ * (e.g. cost/token totals, completion time) stayed stale until app restart. */
181+ private val messageInfoUpdatedSincePivot = mutableSetOf<String>()
182+
183+ /** Mark the point a reconnect re-seed's REST fetch begins: parts/info streamed from here
184+ * on are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
185+ fun beginReseed() {
186+ streamedSincePivot.clear()
187+ messageInfoUpdatedSincePivot.clear()
188+ }
177189
178190 /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
179191 private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
@@ -220,7 +232,11 @@ internal class MessageStore {
220232 for (p in existing.parts) {
221233 if (p.id !in snapshotIds) ordered.add(p)
222234 }
223− val info = if (existing.info is UnknownMessage) m.info else existing.info
235+ // Adopt the authoritative REST info unless the in-memory info was itself
236+ // updated live since the pivot (i.e. during this fetch), in which case it's
237+ // newer than the snapshot. Otherwise the REST snapshot wins, discarding
238+ // in-memory info that went stale during a disconnect.
239+ val info = if (existing.info is UnknownMessage || m.info.id !in messageInfoUpdatedSincePivot) m.info else existing.info
224240 val merged = MessageWithParts(info = info, parts = ordered)
225241 if (merged != existing) {
226242 messages[m.info.id] = merged
@@ -229,9 +245,10 @@ internal class MessageStore {
229245 }
230246 }
231247 if (evictOldMessages()) changed = true
232− // Reset the pivot: everything just merged is now the baseline, so only parts that
233− // stream in *after* this seed can outrank the next reconnect's snapshot.
248+ // 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.
234250 streamedSincePivot.clear()
251+ messageInfoUpdatedSincePivot.clear()
235252 return changed
236253 }
237254
@@ -254,6 +271,10 @@ internal class MessageStore {
254271 }
255272 val existing = messages[key]
256273 messages[key] = existing?.copy(info = info) ?: MessageWithParts(info)
274+ // Record that this message's info streamed in live, so a re-seed after a
275+ // reconnect knows it's newer than the REST snapshot and keeps it (see
276+ // seed()/beginReseed()).
277+ messageInfoUpdatedSincePivot.add(key)
257278 if (existing == null) evictOldMessages()
258279 true
259280 }
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+13−2
@@ -223,7 +223,7 @@ open class AppContainer private constructor(
223223 val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
224224 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
225225 if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
226− draftStore.let { if (!skipInit) it.shutdown() }
226+ if (!skipInit) draftStore.shutdown()
227227 }
228228
229229 /**
@@ -266,6 +266,11 @@ open class AppContainer private constructor(
266266 return@collect
267267 }
268268 val sid = sessionOf(event) ?: return@collect
269+ // Re-read currentSession here, right before mutating the badge state:
270+ // the user may have opened this session between when this event was
271+ // enqueued and now (setCurrentSession clears its badge and dedup set).
272+ // A stale earlier check would race a badge — and a dedup entry — back
273+ // in for the session that's actually on screen.
269274 if (sid != _currentSession.value) {
270275 // Increment the unread count once per distinct *message*, not per
271276 // event: a single reply emits many message.updated / message.part.
@@ -276,7 +281,13 @@ open class AppContainer private constructor(
276281 java.util.Collections.synchronizedSet(mutableSetOf())
277282 }
278283 if (messageId == null || counted.add(messageId)) {
279− _unread.update { it + (sid to (it[sid] ?: 0) + 1) }
284+ _unread.update { current ->
285+ // Guard again inside the atomic update lambda so a retry
286+ // (or an open that landed just now) can't reintroduce the
287+ // badge for the session on screen.
288+ if (sid == _currentSession.value) current
289+ else current + (sid to (current[sid] ?: 0) + 1)
290+ }
280291 }
281292 }
282293 // Track sessions actively streaming so we know which idle events
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+16
@@ -402,6 +402,22 @@ fun ChatScreen(
402402 if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
403403 }
404404
405+ // When a run starts, the LazyColumn gains a trailing "__typing" row, so
406+ // effectiveLast jumps from messages.lastIndex to messages.size and the
407+ // isPinnedToBottom check flips to false the instant you send — freezing
408+ // auto-scroll. If the user was pinned to the last message just before the run
409+ // began, bring the typing row into view so the pin (and streaming follow) is
410+ // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
411+ // recomputed to false by now) so we don't scroll when the user had scrolled up.
412+ LaunchedEffect(running) {
413+ if (running && messages.isNotEmpty()) {
414+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
415+ if (lastVisible >= messages.lastIndex) {
416+ runCatchingCancellable { listState.animateScrollToItem(messages.size) }
417+ }
418+ }
419+ }
420+
405421 LaunchedEffect(Unit) {
406422 snapshotFlow {
407423 // Track list size + the streaming length of the last part so we auto-scroll
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+18−3
@@ -386,7 +386,13 @@ class ChatViewModel(
386386 onSuccess = { resp ->
387387 val options = resp.toOptions()
388388 _models.value = options
389− _selectedModel.value = resp.defaultOption(options)
389+ // Only (re)apply the server default when there's no valid current
390+ // selection. On re-fetch/reconnect/Retry the user's chosen model must
391+ // survive — keep it as long as it's still present in the refreshed list.
392+ val current = _selectedModel.value
393+ if (current == null || options.none { it.ref == current.ref }) {
394+ _selectedModel.value = resp.defaultOption(options)
395+ }
390396 },
391397 onNull = { _models.value = emptyList(); _selectedModel.value = null },
392398 reloadTrigger = _modelsReload,
@@ -457,8 +463,14 @@ class ChatViewModel(
457463 // would be lost forever. The persisted draft is cleared only on success.
458464 // suppressDraftPersist prevents the debounced collector from persisting the
459465 // empty draft before the send resolves.
460− suppressDraftPersist.set(true)
461− _draft.value = ""
466+ //
467+ // Only clear when the text being sent IS the current draft. send() is also
468+ // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
469+ // the user may have typed a NEW draft since — wiping it would lose that text.
470+ if (_draft.value.trim() == trimmed) {
471+ suppressDraftPersist.set(true)
472+ _draft.value = ""
473+ }
462474 viewModelScope.launch {
463475 runCatchingCancellable {
464476 conn.repository.sendPrompt(
@@ -556,6 +568,9 @@ class ChatViewModel(
556568
557569 fun abort() {
558570 val conn = connection ?: return
571+ // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
572+ // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
573+ _queuedFollowUp.value = null
559574 if (!_aborting.compareAndSet(false, true)) return
560575 viewModelScope.launch {
561576 try {
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+8−2
@@ -31,7 +31,8 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
3131 .mapNotNull { part ->
3232 when (part) {
3333 is TextPart -> part.text.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
34− is ReasoningPart -> part.text.takeIf { it.isNotBlank() }?.let { "> _${escapeMarkdown(it)}_" }
34+ is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
35+ ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
3536 is ToolPart -> formatToolCall(part)
3637 else -> null
3738 }
@@ -69,7 +70,12 @@ private fun formatToolCall(part: ToolPart): String? {
6970 return buildList {
7071 add("> $head")
7172 if (titleLine != null) add("> $titleLine")
72− if (detail != null) add(">\n> ```\n$detail\n```")
73+ // Prefix every detail line with "> " so the fenced code block stays inside the
74+ // blockquote — otherwise lines after the first fall out of the quote.
75+ if (detail != null) {
76+ val quoted = detail.lines().joinToString("\n") { "> $it" }
77+ add(">\n> ```\n$quoted\n> ```")
78+ }
7379 }.joinToString("\n").takeIf { it.isNotBlank() }
7480}
7581
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+26−9
@@ -216,20 +216,37 @@ private fun findStringEnd(line: String, start: Int, quote: Char): Int {
216216 return if (i < n) i + 1 else n
217217}
218218
219−/** Scan forward to the end of a number literal starting at [start]. */
219+/** Scan forward to the end of a number literal starting at [start].
220+ *
221+ * A small state machine so we don't over-consume neighbouring source: `5-3`, `1..10`
222+ * and `123abc` must split rather than merge into one token.
223+ * - `+`/`-` are consumed only immediately after an `e`/`E` (an exponent sign).
224+ * - `e`/`E` continues a decimal number at most once, and never after a `0x` prefix.
225+ * - hex digits `a-f`/`A-F` and the `x`/`X` marker are consumed only for a `0x`/`0X` literal.
226+ * - a single `.` continues a decimal number; a second `.` stops the scan (so `1..10` splits).
227+ */
220228private fun findNumberEnd(line: String, start: Int): Int {
221− var i = start + 1
222229 val n = line.length
223− while (i < n && isNumberPartChar(line[i])) i++
230+ // Detect a hex prefix ("0x"/"0X") right at the number start.
231+ val hex = start + 1 < n && line[start] == '0' && (line[start + 1] == 'x' || line[start + 1] == 'X')
232+ var i = if (hex) start + 2 else start + 1
233+ var seenDot = line[start] == '.'
234+ var seenExp = false
235+ while (i < n) {
236+ val c = line[i]
237+ val prev = line[i - 1]
238+ when {
239+ c.isDigit() || c == '_' -> i++
240+ hex && isHexDigit(c) -> i++
241+ c == '.' && !hex && !seenDot && !seenExp -> { seenDot = true; i++ }
242+ !hex && !seenExp && (c == 'e' || c == 'E') -> { seenExp = true; i++ }
243+ !hex && (c == '+' || c == '-') && (prev == 'e' || prev == 'E') -> i++
244+ else -> break
245+ }
246+ }
224247 return i
225248}
226249
227−private fun isNumberPartChar(c: Char): Boolean =
228− c.isDigit() || c == '.' || c == '_' || isNumberExpPart(c) || isHexDigit(c)
229−
230−private fun isNumberExpPart(c: Char): Boolean =
231− c == 'x' || c == 'X' || c == 'e' || c == 'E' || c == '+' || c == '-'
232−
233250private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
234251
235252/** Scan forward to the end of an identifier starting at [start]. */
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+17−5
@@ -53,17 +53,29 @@ private val gitMetaPrefixes = listOf(
5353 "copy from ", "copy to ", "Binary files ", "\\ No newline",
5454)
5555
56+/** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null")
57+ * rather than a removed/added content line that merely starts with "--- "/"+++ "? */
58+private fun isDiffFileHeader(raw: String): Boolean {
59+ val rest = when {
60+ raw.startsWith("--- ") -> raw.substring(4)
61+ raw.startsWith("+++ ") -> raw.substring(4)
62+ else -> return false
63+ }
64+ return rest.startsWith("a/") || rest.startsWith("b/") || rest.startsWith("/")
65+}
66+
5667/** Parse a unified diff string into typed [DiffLine]s. */
5768fun parseDiff(diff: String): List<DiffLine> {
5869 val result = mutableListOf<DiffLine>()
5970 for (raw in diff.lineSequence()) {
6071 when {
6172 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
62− // Unified-diff file headers are always "--- <path>" / "+++ <path>" (a space
63− // follows the marker). Requiring that space avoids misclassifying removed or
64− // added *content* lines like "--foo"/"++bar" — which appear on the wire as
65− // "---foo"/"+++bar" — as headers; those fall through to the +/- branches below.
66− raw.startsWith("--- ") || raw.startsWith("+++ ") -> result.add(DiffLine.FileHeader(raw))
73+ // Unified-diff file headers are "--- <path>" / "+++ <path>" where the path is
74+ // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker
75+ // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
76+ // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" —
77+ // as headers; those fall through to the +/- branches below.
78+ isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
6779 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
6880 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
6981 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+5
@@ -116,6 +116,11 @@ fun FileViewScreen(
116116 else lines.mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
117117 .filter { it >= 0 }
118118 }
119+ // Keep the current match index in range when the match set shrinks (e.g. the file
120+ // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
121+ LaunchedEffect(matchIndices.size) {
122+ matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
123+ }
119124 // Scroll to the current match whenever it (or the match set) changes.
120125 LaunchedEffect(matchPos, matchIndices) {
121126 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+2−2
@@ -96,7 +96,7 @@ class ServerEditViewModel(
9696 if (profileId != null) {
9797 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
9898 container.profileStore.profiles
99− .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
99+ .first { list -> list.any { p -> p.id == profileId } }
100100 .firstOrNull { it.id == profileId }
101101 }
102102 if (existing != null) {
@@ -131,7 +131,7 @@ class ServerEditViewModel(
131131 if (sourceId != null) {
132132 val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
133133 container.profileStore.profiles
134− .first { it.isNotEmpty() || it.none { p -> p.id == sourceId } }
134+ .first { list -> list.any { p -> p.id == sourceId } }
135135 .firstOrNull { it.id == sourceId }
136136 }
137137 if (source != null) {
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+23−10
@@ -61,7 +61,11 @@ import androidx.compose.ui.unit.dp
6161import androidx.compose.ui.res.stringResource
6262import androidx.lifecycle.compose.collectAsStateWithLifecycle
6363import androidx.lifecycle.viewmodel.compose.viewModel
64+import kotlinx.coroutines.coroutineScope
65+import kotlinx.coroutines.delay
66+import kotlinx.coroutines.launch
6467import soy.iko.opencode.data.model.ServerProfile
68+import soy.iko.opencode.data.network.NetworkConfig
6569import soy.iko.opencode.di.AppContainer
6670import soy.iko.opencode.R
6771import soy.iko.opencode.ui.components.rememberRelativeTime
@@ -104,18 +108,27 @@ fun ServerListScreen(
104108
105109 // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
106110 // action is taken before the delay expires, the profile is kept and the delete
107− // never fires — mirrors the session list's undo UX for consistency. Use Long
108− // (~10s) so the Undo button outlasts the undoServerDeleteDelayMs window (5s);
109− // Short (~1.5s) vanished before the delete fired, leaving no way to cancel.
111+ // never fires — mirrors the session list's undo UX for consistency. Indefinite + a
112+ // matching timed dismiss keeps the Undo button visible for exactly the
113+ // undoServerDeleteDelayMs window (5s) and no longer: a fixed SnackbarDuration.Long
114+ // (~10s) outlasted the window, leaving the button on screen but dead for its
115+ // second half. Mirrors DiagnosticsScreen's undo pattern.
110116 LaunchedEffect(Unit) {
111117 vm.undoEvents.collect { profileId ->
112− val result = snackbar.showSnackbar(
113− message = serverRemovedLabel,
114− actionLabel = undoLabel,
115− duration = SnackbarDuration.Long,
116− )
117− if (result == SnackbarResult.ActionPerformed) {
118− vm.undoDelete(profileId)
118+ coroutineScope {
119+ val dismisser = launch {
120+ delay(NetworkConfig.undoServerDeleteDelayMs)
121+ snackbar.currentSnackbarData?.dismiss()
122+ }
123+ val result = snackbar.showSnackbar(
124+ message = serverRemovedLabel,
125+ actionLabel = undoLabel,
126+ duration = SnackbarDuration.Indefinite,
127+ )
128+ dismisser.cancel()
129+ if (result == SnackbarResult.ActionPerformed) {
130+ vm.undoDelete(profileId)
131+ }
119132 }
120133 }
121134 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+24−3
@@ -16,7 +16,9 @@ import kotlinx.coroutines.flow.SharingStarted
1616import kotlinx.coroutines.flow.StateFlow
1717import kotlinx.coroutines.flow.asSharedFlow
1818import kotlinx.coroutines.flow.asStateFlow
19+import kotlinx.coroutines.flow.combine
1920import kotlinx.coroutines.flow.stateIn
21+import kotlinx.coroutines.flow.update
2022import kotlinx.coroutines.launch
2123
2224/** A transient error surfaced as a snackbar, optionally paired with the profile that
@@ -25,8 +27,15 @@ data class ConnectError(val message: String, val profile: ServerProfile?)
2527
2628class ServerListViewModel(private val container: AppContainer) : ViewModel() {
2729
30+ /** Ids optimistically hidden while their deferred delete's undo window is open, so a
31+ * "deleted" row disappears immediately instead of lingering (tappable) for the whole
32+ * undo window. Mirrors [SessionListViewModel]'s optimistic-removal pattern. */
33+ private val _hiddenIds = MutableStateFlow<Set<String>>(emptySet())
34+
2835 val profiles: StateFlow<List<ServerProfile>> =
29− container.profileStore.profiles.stateIn(
36+ combine(container.profileStore.profiles, _hiddenIds) { profiles, hidden ->
37+ profiles.filterNot { it.id in hidden }
38+ }.stateIn(
3039 scope = viewModelScope,
3140 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
3241 initialValue = emptyList(),
@@ -96,6 +105,8 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
96105 */
97106 fun delete(profile: ServerProfile) {
98107 pendingDeletes[profile.id] = profile
108+ // Optimistically hide the row so the UI feels instant; undo re-shows it.
109+ _hiddenIds.update { it + profile.id }
99110 if (container.activeConnection.value?.profile?.id == profile.id) {
100111 // disconnect() is suspend; run it on the VM scope so the optimistic removal
101112 // and undo emission aren't blocked on the connection close completing.
@@ -106,13 +117,23 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
106117 delay(NetworkConfig.undoServerDeleteDelayMs)
107118 if (pendingDeletes.remove(profile.id) != null) {
108119 runCatchingCancellable { container.profileStore.delete(profile.id) }
109− .onFailure { _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null)) }
120+ .onSuccess {
121+ // Drop the optimistic hide only after the store has emitted the
122+ // removal, so the row doesn't flash back before the list updates.
123+ _hiddenIds.update { it - profile.id }
124+ }
125+ .onFailure {
126+ // Delete failed: re-show the row so it isn't hidden forever.
127+ _hiddenIds.update { it - profile.id }
128+ _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null))
129+ }
110130 }
111131 }
112132 }
113133
114− /** Cancel a pending delete (Undo snackbar action). */
134+ /** Cancel a pending delete (Undo snackbar action) and re-show the hidden row. */
115135 fun undoDelete(profileId: String) {
116136 pendingDeletes.remove(profileId)
137+ _hiddenIds.update { it - profileId }
117138 }
118139}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+21−10
@@ -77,6 +77,9 @@ import androidx.compose.ui.unit.dp
7777import androidx.compose.ui.res.stringResource
7878import androidx.lifecycle.compose.collectAsStateWithLifecycle
7979import androidx.lifecycle.viewmodel.compose.viewModel
80+import kotlinx.coroutines.coroutineScope
81+import kotlinx.coroutines.delay
82+import kotlinx.coroutines.launch
8083import soy.iko.opencode.data.model.ServerProfile
8184import soy.iko.opencode.data.model.Session
8285import soy.iko.opencode.data.network.NetworkConfig
@@ -138,18 +141,26 @@ fun SessionListScreen(
138141
139142 // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
140143 // action is taken before the delay expires, the session is restored and the REST
141− // delete never fires. Use Long (~10s) so the Undo button stays available for the
142− // full undoDeleteDelayMs window (5s); Short (~1.5s) vanished before the delete
143− // fired, leaving the user unable to cancel during the last 3.5s.
144+ // delete never fires. Indefinite + a matching timed dismiss keeps the Undo button
145+ // visible for exactly the undoDeleteDelayMs window (5s) and no longer: a fixed
146+ // SnackbarDuration.Long (~10s) outlasted the window, leaving the button on screen
147+ // but dead for its second half. Mirrors DiagnosticsScreen's undo pattern.
144148 LaunchedEffect(Unit) {
145149 vm.undoEvents.collect { sessionId ->
146− val result = snackbar.showSnackbar(
147− message = sessionDeletedLabel,
148− actionLabel = undoLabel,
149− duration = androidx.compose.material3.SnackbarDuration.Long,
150− )
151− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
152− vm.undoDelete(sessionId)
150+ coroutineScope {
151+ val dismisser = launch {
152+ delay(NetworkConfig.undoDeleteDelayMs)
153+ snackbar.currentSnackbarData?.dismiss()
154+ }
155+ val result = snackbar.showSnackbar(
156+ message = sessionDeletedLabel,
157+ actionLabel = undoLabel,
158+ duration = androidx.compose.material3.SnackbarDuration.Indefinite,
159+ )
160+ dismisser.cancel()
161+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
162+ vm.undoDelete(sessionId)
163+ }
153164 }
154165 }
155166 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+5−1
@@ -230,7 +230,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
230230 private fun flushPendingSessionUpdates() {
231231 val batch = pendingSessionUpdates.toMap()
232232 if (batch.isEmpty()) return
233− pendingSessionUpdates.clear()
233+ // Remove only the snapshotted key/value pairs rather than clear()-ing the whole
234+ // map: an update for a session that arrived between the toMap() snapshot and here
235+ // would otherwise be dropped. The key/value remove overload also leaves a newer
236+ // value for the same key in place to be flushed on the next pass.
237+ batch.forEach { (k, v) -> pendingSessionUpdates.remove(k, v) }
234238 _state.update { s ->
235239 val byId = s.sessions.associateBy { it.id }.toMutableMap()
236240 var anyChanged = false