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)
cec3303iko
app/src/main/java/soy/iko/opencode/MainActivity.kt+17−2
@@ -28,12 +28,16 @@ class MainActivity : ComponentActivity() {
28 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
28 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
29
29
30 private var shareIntentHandled = false
30 private var shareIntentHandled = false
31+ private var openSessionHandled = false
31
32
32 override fun onCreate(savedInstanceState: Bundle?) {
33 override fun onCreate(savedInstanceState: Bundle?) {
33 val splash = installSplashScreen()
34 val splash = installSplashScreen()
34 super.onCreate(savedInstanceState)
35 super.onCreate(savedInstanceState)
35 enableEdgeToEdge()
36 enableEdgeToEdge()
37+ savedInstanceState?.let {
36− savedInstanceState?.let { shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false) }
38+ shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
39+ openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
40+ }
37 val container = (application as OpencodeApp).container
41 val container = (application as OpencodeApp).container
38 handleIntent(intent)
42 handleIntent(intent)
39 if (savedInstanceState == null) maybeRequestNotificationPermission()
43 if (savedInstanceState == null) maybeRequestNotificationPermission()
@@ -65,6 +69,7 @@ class MainActivity : ComponentActivity() {
65 super.onNewIntent(intent)
69 super.onNewIntent(intent)
66 setIntent(intent)
70 setIntent(intent)
67 shareIntentHandled = false
71 shareIntentHandled = false
72+ openSessionHandled = false
68 handleIntent(intent)
73 handleIntent(intent)
69 }
74 }
70
75
@@ -90,7 +95,15 @@ class MainActivity : ComponentActivity() {
90 // Validate: session ids are opaque server-generated identifiers. Reject anything
95 // Validate: session ids are opaque server-generated identifiers. Reject anything
91 // with path separators or other traversal/control characters so a malicious deep
96 // with path separators or other traversal/control characters so a malicious deep
92 // link can't inject path components into the REST URL path.
97 // link can't inject path components into the REST URL path.
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) {
93− id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let { container.requestOpenSession(it) }
102+ id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
103+ container.requestOpenSession(it)
104+ openSessionHandled = true
105+ }
106+ }
94 }
107 }
95
108
96 companion object {
109 companion object {
@@ -101,11 +114,13 @@ class MainActivity : ComponentActivity() {
101 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
114 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
102
115
103 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
116 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
117+ private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
104 }
118 }
105
119
106 override fun onSaveInstanceState(outState: Bundle) {
120 override fun onSaveInstanceState(outState: Bundle) {
107 super.onSaveInstanceState(outState)
121 super.onSaveInstanceState(outState)
108 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
122 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
123+ outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
109 }
124 }
110
125
111 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
126 /** 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(
147 }
147 }
148 },
148 },
149 ) {
149 ) {
150− backoffMs = initialBackoffMs
151 // The SSE block is open — the connection is established. Mark as
150 // The SSE block is open — the connection is established. Mark as
152 // Connected immediately so downstream re-seed logic fires even when
151 // Connected immediately so downstream re-seed logic fires even when
153 // the server only sends keep-alive comments (no data events).
152 // the server only sends keep-alive comments (no data events).
@@ -161,8 +160,19 @@ open class EventStreamClient(
161 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
160 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
162 // element can be raced against an idle timeout via select.
161 // element can be raced against an idle timeout via select.
163 val events = incoming.produceIn(scope)
162 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
164 try {
168 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+ }
166 } finally {
176 } finally {
167 events.cancel()
177 events.cancel()
168 }
178 }
@@ -177,9 +187,14 @@ open class EventStreamClient(
177 // Credentials are wrong — retrying won't help. Log a scrubbed
187 // Credentials are wrong — retrying won't help. Log a scrubbed
178 // summary (class + status) instead of the full exception, whose
188 // summary (class + status) instead of the full exception, whose
179 // message carries the request URL and may include auth or paths.
189 // 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)}")
181 _state.value = ConnectionState.Failed
191 _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
183 } else if (isActive) {
198 } else if (isActive) {
184 Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
199 Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
185 }
200 }
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+5−2
@@ -53,7 +53,7 @@ object HttpClientFactory {
53 }
53 }
54
54
55 val normalizedUrl = normalizeBaseUrl(profile.baseUrl)
55 val normalizedUrl = normalizeBaseUrl(profile.baseUrl)
56− val isHttps = normalizedUrl.startsWith("https://")
56+ val isHttps = normalizedUrl.lowercase().startsWith("https://")
57
57
58 if (profile.hasAuth) {
58 if (profile.hasAuth) {
59 if (isHttps) {
59 if (isHttps) {
@@ -101,7 +101,10 @@ object HttpClientFactory {
101 * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
101 * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
102 fun normalizeBaseUrl(raw: String): String {
102 fun normalizeBaseUrl(raw: String): String {
103 val trimmed = raw.trim()
103 val trimmed = raw.trim()
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()
104− val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
107+ val withScheme = if (lowerTrimmed.startsWith("http://") || lowerTrimmed.startsWith("https://")) {
105 trimmed
108 trimmed
106 } else {
109 } else {
107 "https://$trimmed"
110 "https://$trimmed"
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.kt+1
@@ -25,6 +25,7 @@ val OpencodeJson: Json = Json {
25 ignoreUnknownKeys = true
25 ignoreUnknownKeys = true
26 isLenient = true
26 isLenient = true
27 coerceInputValues = true
27 coerceInputValues = true
28+ encodeDefaults = true
28 explicitNulls = false
29 explicitNulls = false
29 classDiscriminator = "type"
30 classDiscriminator = "type"
30 serializersModule = SerializersModule {
31 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) {
106 if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
106 if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
107 file.delete()
107 file.delete()
108 }
108 }
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.
109− pendingDeletes.remove(fileName)
112+ coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) }
110 refresh()
113 refresh()
111 }
114 }
112 pendingDeletes.put(fileName, job)?.cancel()
115 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 {
171 * (e.g. a tool going running -> completed) stayed stale until app restart. */
171 * (e.g. a tool going running -> completed) stayed stale until app restart. */
172 private val streamedSincePivot = mutableSetOf<String>()
172 private val streamedSincePivot = mutableSetOf<String>()
173
173
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+
174− /** Mark the point a reconnect re-seed's REST fetch begins: parts streamed from here on
183+ /** Mark the point a reconnect re-seed's REST fetch begins: parts/info streamed from here
175− * are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
184+ * on are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
185+ fun beginReseed() {
176− fun beginReseed() { streamedSincePivot.clear() }
186+ streamedSincePivot.clear()
187+ messageInfoUpdatedSincePivot.clear()
188+ }
177
189
178 /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
190 /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
179 private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
191 private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
@@ -220,7 +232,11 @@ internal class MessageStore {
220 for (p in existing.parts) {
232 for (p in existing.parts) {
221 if (p.id !in snapshotIds) ordered.add(p)
233 if (p.id !in snapshotIds) ordered.add(p)
222 }
234 }
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.
223− val info = if (existing.info is UnknownMessage) m.info else existing.info
239+ val info = if (existing.info is UnknownMessage || m.info.id !in messageInfoUpdatedSincePivot) m.info else existing.info
224 val merged = MessageWithParts(info = info, parts = ordered)
240 val merged = MessageWithParts(info = info, parts = ordered)
225 if (merged != existing) {
241 if (merged != existing) {
226 messages[m.info.id] = merged
242 messages[m.info.id] = merged
@@ -229,9 +245,10 @@ internal class MessageStore {
229 }
245 }
230 }
246 }
231 if (evictOldMessages()) changed = true
247 if (evictOldMessages()) changed = true
232− // Reset the pivot: everything just merged is now the baseline, so only parts that
248+ // Reset the pivot: everything just merged is now the baseline, so only parts/info
233− // stream in *after* this seed can outrank the next reconnect's snapshot.
249+ // that stream in *after* this seed can outrank the next reconnect's snapshot.
234 streamedSincePivot.clear()
250 streamedSincePivot.clear()
251+ messageInfoUpdatedSincePivot.clear()
235 return changed
252 return changed
236 }
253 }
237
254
@@ -254,6 +271,10 @@ internal class MessageStore {
254 }
271 }
255 val existing = messages[key]
272 val existing = messages[key]
256 messages[key] = existing?.copy(info = info) ?: MessageWithParts(info)
273 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)
257 if (existing == null) evictOldMessages()
278 if (existing == null) evictOldMessages()
258 true
279 true
259 }
280 }
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+13−2
@@ -223,7 +223,7 @@ open class AppContainer private constructor(
223 val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
223 val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
224 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
224 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
225 if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
225 if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
226− draftStore.let { if (!skipInit) it.shutdown() }
226+ if (!skipInit) draftStore.shutdown()
227 }
227 }
228
228
229 /**
229 /**
@@ -266,6 +266,11 @@ open class AppContainer private constructor(
266 return@collect
266 return@collect
267 }
267 }
268 val sid = sessionOf(event) ?: return@collect
268 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.
269 if (sid != _currentSession.value) {
274 if (sid != _currentSession.value) {
270 // Increment the unread count once per distinct *message*, not per
275 // Increment the unread count once per distinct *message*, not per
271 // event: a single reply emits many message.updated / message.part.
276 // event: a single reply emits many message.updated / message.part.
@@ -276,7 +281,13 @@ open class AppContainer private constructor(
276 java.util.Collections.synchronizedSet(mutableSetOf())
281 java.util.Collections.synchronizedSet(mutableSetOf())
277 }
282 }
278 if (messageId == null || counted.add(messageId)) {
283 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+ }
280 }
291 }
281 }
292 }
282 // Track sessions actively streaming so we know which idle events
293 // 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(
402 if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
402 if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
403 }
403 }
404
404
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+
405 LaunchedEffect(Unit) {
421 LaunchedEffect(Unit) {
406 snapshotFlow {
422 snapshotFlow {
407 // Track list size + the streaming length of the last part so we auto-scroll
423 // 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(
386 onSuccess = { resp ->
386 onSuccess = { resp ->
387 val options = resp.toOptions()
387 val options = resp.toOptions()
388 _models.value = options
388 _models.value = 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 }) {
389− _selectedModel.value = resp.defaultOption(options)
394+ _selectedModel.value = resp.defaultOption(options)
395+ }
390 },
396 },
391 onNull = { _models.value = emptyList(); _selectedModel.value = null },
397 onNull = { _models.value = emptyList(); _selectedModel.value = null },
392 reloadTrigger = _modelsReload,
398 reloadTrigger = _modelsReload,
@@ -457,8 +463,14 @@ class ChatViewModel(
457 // would be lost forever. The persisted draft is cleared only on success.
463 // would be lost forever. The persisted draft is cleared only on success.
458 // suppressDraftPersist prevents the debounced collector from persisting the
464 // suppressDraftPersist prevents the debounced collector from persisting the
459 // empty draft before the send resolves.
465 // empty draft before the send resolves.
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) {
460− suppressDraftPersist.set(true)
471+ suppressDraftPersist.set(true)
461− _draft.value = ""
472+ _draft.value = ""
473+ }
462 viewModelScope.launch {
474 viewModelScope.launch {
463 runCatchingCancellable {
475 runCatchingCancellable {
464 conn.repository.sendPrompt(
476 conn.repository.sendPrompt(
@@ -556,6 +568,9 @@ class ChatViewModel(
556
568
557 fun abort() {
569 fun abort() {
558 val conn = connection ?: return
570 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
559 if (!_aborting.compareAndSet(false, true)) return
574 if (!_aborting.compareAndSet(false, true)) return
560 viewModelScope.launch {
575 viewModelScope.launch {
561 try {
576 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?):
31 .mapNotNull { part ->
31 .mapNotNull { part ->
32 when (part) {
32 when (part) {
33 is TextPart -> part.text.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
33 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)}_" } }
35 is ToolPart -> formatToolCall(part)
36 is ToolPart -> formatToolCall(part)
36 else -> null
37 else -> null
37 }
38 }
@@ -69,7 +70,12 @@ private fun formatToolCall(part: ToolPart): String? {
69 return buildList {
70 return buildList {
70 add("> $head")
71 add("> $head")
71 if (titleLine != null) add("> $titleLine")
72 if (titleLine != null) add("> $titleLine")
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.
72− if (detail != null) add(">\n> ```\n$detail\n```")
75+ if (detail != null) {
76+ val quoted = detail.lines().joinToString("\n") { "> $it" }
77+ add(">\n> ```\n$quoted\n> ```")
78+ }
73 }.joinToString("\n").takeIf { it.isNotBlank() }
79 }.joinToString("\n").takeIf { it.isNotBlank() }
74}
80}
75
81
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 {
216 return if (i < n) i + 1 else n
216 return if (i < n) i + 1 else n
217}
217}
218
218
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+ */
220private fun findNumberEnd(line: String, start: Int): Int {
228private fun findNumberEnd(line: String, start: Int): Int {
221− var i = start + 1
222 val n = line.length
229 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+ }
224 return i
247 return i
225}
248}
226
249
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−
233private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
250private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
234
251
235/** Scan forward to the end of an identifier starting at [start]. */
252/** 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(
53 "copy from ", "copy to ", "Binary files ", "\\ No newline",
53 "copy from ", "copy to ", "Binary files ", "\\ No newline",
54)
54)
55
55
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+
56/** Parse a unified diff string into typed [DiffLine]s. */
67/** Parse a unified diff string into typed [DiffLine]s. */
57fun parseDiff(diff: String): List<DiffLine> {
68fun parseDiff(diff: String): List<DiffLine> {
58 val result = mutableListOf<DiffLine>()
69 val result = mutableListOf<DiffLine>()
59 for (raw in diff.lineSequence()) {
70 for (raw in diff.lineSequence()) {
60 when {
71 when {
61 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
72 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
62− // Unified-diff file headers are always "--- <path>" / "+++ <path>" (a space
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
63− // follows the marker). Requiring that space avoids misclassifying removed or
75+ // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
64− // added *content* lines like "--foo"/"++bar" — which appear on the wire as
76+ // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" —
65− // "---foo"/"+++bar" — as headers; those fall through to the +/- branches below.
77+ // as headers; those fall through to the +/- branches below.
66− raw.startsWith("--- ") || raw.startsWith("+++ ") -> result.add(DiffLine.FileHeader(raw))
78+ isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
67 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
79 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
68 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
80 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
69 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
81 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(
116 else lines.mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
116 else lines.mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
117 .filter { it >= 0 }
117 .filter { it >= 0 }
118 }
118 }
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+ }
119 // Scroll to the current match whenever it (or the match set) changes.
124 // Scroll to the current match whenever it (or the match set) changes.
120 LaunchedEffect(matchPos, matchIndices) {
125 LaunchedEffect(matchPos, matchIndices) {
121 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
126 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(
96 if (profileId != null) {
96 if (profileId != null) {
97 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
97 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
98 container.profileStore.profiles
98 container.profileStore.profiles
99− .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
99+ .first { list -> list.any { p -> p.id == profileId } }
100 .firstOrNull { it.id == profileId }
100 .firstOrNull { it.id == profileId }
101 }
101 }
102 if (existing != null) {
102 if (existing != null) {
@@ -131,7 +131,7 @@ class ServerEditViewModel(
131 if (sourceId != null) {
131 if (sourceId != null) {
132 val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
132 val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
133 container.profileStore.profiles
133 container.profileStore.profiles
134− .first { it.isNotEmpty() || it.none { p -> p.id == sourceId } }
134+ .first { list -> list.any { p -> p.id == sourceId } }
135 .firstOrNull { it.id == sourceId }
135 .firstOrNull { it.id == sourceId }
136 }
136 }
137 if (source != null) {
137 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
61import androidx.compose.ui.res.stringResource
61import androidx.compose.ui.res.stringResource
62import androidx.lifecycle.compose.collectAsStateWithLifecycle
62import androidx.lifecycle.compose.collectAsStateWithLifecycle
63import androidx.lifecycle.viewmodel.compose.viewModel
63import androidx.lifecycle.viewmodel.compose.viewModel
64+import kotlinx.coroutines.coroutineScope
65+import kotlinx.coroutines.delay
66+import kotlinx.coroutines.launch
64import soy.iko.opencode.data.model.ServerProfile
67import soy.iko.opencode.data.model.ServerProfile
68+import soy.iko.opencode.data.network.NetworkConfig
65import soy.iko.opencode.di.AppContainer
69import soy.iko.opencode.di.AppContainer
66import soy.iko.opencode.R
70import soy.iko.opencode.R
67import soy.iko.opencode.ui.components.rememberRelativeTime
71import soy.iko.opencode.ui.components.rememberRelativeTime
@@ -104,18 +108,27 @@ fun ServerListScreen(
104
108
105 // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
109 // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
106 // action is taken before the delay expires, the profile is kept and the delete
110 // 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
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
108− // (~10s) so the Undo button outlasts the undoServerDeleteDelayMs window (5s);
113+ // undoServerDeleteDelayMs window (5s) and no longer: a fixed SnackbarDuration.Long
109− // Short (~1.5s) vanished before the delete fired, leaving no way to cancel.
114+ // (~10s) outlasted the window, leaving the button on screen but dead for its
115+ // second half. Mirrors DiagnosticsScreen's undo pattern.
110 LaunchedEffect(Unit) {
116 LaunchedEffect(Unit) {
111 vm.undoEvents.collect { profileId ->
117 vm.undoEvents.collect { profileId ->
118+ coroutineScope {
119+ val dismisser = launch {
120+ delay(NetworkConfig.undoServerDeleteDelayMs)
121+ snackbar.currentSnackbarData?.dismiss()
122+ }
112− val result = snackbar.showSnackbar(
123+ val result = snackbar.showSnackbar(
113− message = serverRemovedLabel,
124+ message = serverRemovedLabel,
114− actionLabel = undoLabel,
125+ actionLabel = undoLabel,
115− duration = SnackbarDuration.Long,
126+ duration = SnackbarDuration.Indefinite,
116− )
127+ )
128+ dismisser.cancel()
117− if (result == SnackbarResult.ActionPerformed) {
129+ if (result == SnackbarResult.ActionPerformed) {
118− vm.undoDelete(profileId)
130+ vm.undoDelete(profileId)
131+ }
119 }
132 }
120 }
133 }
121 }
134 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+24−3
@@ -16,7 +16,9 @@ import kotlinx.coroutines.flow.SharingStarted
16import kotlinx.coroutines.flow.StateFlow
16import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.asSharedFlow
17import kotlinx.coroutines.flow.asSharedFlow
18import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.asStateFlow
19+import kotlinx.coroutines.flow.combine
19import kotlinx.coroutines.flow.stateIn
20import kotlinx.coroutines.flow.stateIn
21+import kotlinx.coroutines.flow.update
20import kotlinx.coroutines.launch
22import kotlinx.coroutines.launch
21
23
22/** A transient error surfaced as a snackbar, optionally paired with the profile that
24/** 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?)
25
27
26class ServerListViewModel(private val container: AppContainer) : ViewModel() {
28class ServerListViewModel(private val container: AppContainer) : ViewModel() {
27
29
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+
28 val profiles: StateFlow<List<ServerProfile>> =
35 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(
30 scope = viewModelScope,
39 scope = viewModelScope,
31 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
40 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
32 initialValue = emptyList(),
41 initialValue = emptyList(),
@@ -96,6 +105,8 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
96 */
105 */
97 fun delete(profile: ServerProfile) {
106 fun delete(profile: ServerProfile) {
98 pendingDeletes[profile.id] = profile
107 pendingDeletes[profile.id] = profile
108+ // Optimistically hide the row so the UI feels instant; undo re-shows it.
109+ _hiddenIds.update { it + profile.id }
99 if (container.activeConnection.value?.profile?.id == profile.id) {
110 if (container.activeConnection.value?.profile?.id == profile.id) {
100 // disconnect() is suspend; run it on the VM scope so the optimistic removal
111 // disconnect() is suspend; run it on the VM scope so the optimistic removal
101 // and undo emission aren't blocked on the connection close completing.
112 // and undo emission aren't blocked on the connection close completing.
@@ -106,13 +117,23 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
106 delay(NetworkConfig.undoServerDeleteDelayMs)
117 delay(NetworkConfig.undoServerDeleteDelayMs)
107 if (pendingDeletes.remove(profile.id) != null) {
118 if (pendingDeletes.remove(profile.id) != null) {
108 runCatchingCancellable { container.profileStore.delete(profile.id) }
119 runCatchingCancellable { container.profileStore.delete(profile.id) }
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 }
109− .onFailure { _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null)) }
128+ _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null))
129+ }
110 }
130 }
111 }
131 }
112 }
132 }
113
133
114− /** Cancel a pending delete (Undo snackbar action). */
134+ /** Cancel a pending delete (Undo snackbar action) and re-show the hidden row. */
115 fun undoDelete(profileId: String) {
135 fun undoDelete(profileId: String) {
116 pendingDeletes.remove(profileId)
136 pendingDeletes.remove(profileId)
137+ _hiddenIds.update { it - profileId }
117 }
138 }
118}
139}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+21−10
@@ -77,6 +77,9 @@ import androidx.compose.ui.unit.dp
77import androidx.compose.ui.res.stringResource
77import androidx.compose.ui.res.stringResource
78import androidx.lifecycle.compose.collectAsStateWithLifecycle
78import androidx.lifecycle.compose.collectAsStateWithLifecycle
79import androidx.lifecycle.viewmodel.compose.viewModel
79import androidx.lifecycle.viewmodel.compose.viewModel
80+import kotlinx.coroutines.coroutineScope
81+import kotlinx.coroutines.delay
82+import kotlinx.coroutines.launch
80import soy.iko.opencode.data.model.ServerProfile
83import soy.iko.opencode.data.model.ServerProfile
81import soy.iko.opencode.data.model.Session
84import soy.iko.opencode.data.model.Session
82import soy.iko.opencode.data.network.NetworkConfig
85import soy.iko.opencode.data.network.NetworkConfig
@@ -138,18 +141,26 @@ fun SessionListScreen(
138
141
139 // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
142 // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
140 // action is taken before the delay expires, the session is restored and the REST
143 // 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
144+ // delete never fires. Indefinite + a matching timed dismiss keeps the Undo button
142− // full undoDeleteDelayMs window (5s); Short (~1.5s) vanished before the delete
145+ // visible for exactly the undoDeleteDelayMs window (5s) and no longer: a fixed
143− // fired, leaving the user unable to cancel during the last 3.5s.
146+ // SnackbarDuration.Long (~10s) outlasted the window, leaving the button on screen
147+ // but dead for its second half. Mirrors DiagnosticsScreen's undo pattern.
144 LaunchedEffect(Unit) {
148 LaunchedEffect(Unit) {
145 vm.undoEvents.collect { sessionId ->
149 vm.undoEvents.collect { sessionId ->
150+ coroutineScope {
151+ val dismisser = launch {
152+ delay(NetworkConfig.undoDeleteDelayMs)
153+ snackbar.currentSnackbarData?.dismiss()
154+ }
146− val result = snackbar.showSnackbar(
155+ val result = snackbar.showSnackbar(
147− message = sessionDeletedLabel,
156+ message = sessionDeletedLabel,
148− actionLabel = undoLabel,
157+ actionLabel = undoLabel,
149− duration = androidx.compose.material3.SnackbarDuration.Long,
158+ duration = androidx.compose.material3.SnackbarDuration.Indefinite,
150− )
159+ )
160+ dismisser.cancel()
151− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
161+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
152− vm.undoDelete(sessionId)
162+ vm.undoDelete(sessionId)
163+ }
153 }
164 }
154 }
165 }
155 }
166 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+5−1
@@ -230,7 +230,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
230 private fun flushPendingSessionUpdates() {
230 private fun flushPendingSessionUpdates() {
231 val batch = pendingSessionUpdates.toMap()
231 val batch = pendingSessionUpdates.toMap()
232 if (batch.isEmpty()) return
232 if (batch.isEmpty()) return
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.
233− pendingSessionUpdates.clear()
237+ batch.forEach { (k, v) -> pendingSessionUpdates.remove(k, v) }
234 _state.update { s ->
238 _state.update { s ->
235 val byId = s.sessions.associateBy { it.id }.toMutableMap()
239 val byId = s.sessions.associateBy { it.id }.toMutableMap()
236 var anyChanged = false
240 var anyChanged = false