git.iko.soy/opencode-android
on master help sign in
Fix queued follow-up loss across process death, a SharedFlow stall from synchronous notification dispatch, and several UI/state races (draft echo clobbering, reasoning collapse during streaming, permission dialog back handling, diff header misclassification, remote image same-origin guard, file browser search refresh, undo snackbar dead-Undo buttons, deleted session resurrected mid-undo, cancellable crash-report read)
bc6b45eiko
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+59−6
@@ -55,6 +55,11 @@ open class DraftStore private constructor(
55 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
55 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
56 open val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
56 open val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
57
57
58+ /** Queued follow-up messages per session, persisted under a namespaced key so a
59+ * follow-up queued mid-run survives process death (the in-memory-only StateFlow in
60+ * ChatViewModel would otherwise lose it, along with the cleared draft). */
61+ private val _followUps = MutableStateFlow<Map<String, String>>(emptyMap())
62+
58 private val _ready = MutableStateFlow(false)
63 private val _ready = MutableStateFlow(false)
59 /** True once the background prefs load has completed. */
64 /** True once the background prefs load has completed. */
60 open val ready: StateFlow<Boolean> = _ready.asStateFlow()
65 open val ready: StateFlow<Boolean> = _ready.asStateFlow()
@@ -65,16 +70,23 @@ open class DraftStore private constructor(
65 // so callers can observe readiness without blocking.
70 // so callers can observe readiness without blocking.
66 if (scope != null && appContext != null) {
71 if (scope != null && appContext != null) {
67 scope.launch(Dispatchers.IO) {
72 scope.launch(Dispatchers.IO) {
68− val snapshot = runCatching {
73+ val all = runCatching {
69 prefs.all.mapValues { it.value as? String ?: "" }
74 prefs.all.mapValues { it.value as? String ?: "" }
70 }.getOrDefault(emptyMap())
75 }.getOrDefault(emptyMap())
76+ // Partition persisted keys: follow-up keys carry a prefix, everything else
77+ // is a plain per-session draft.
78+ val drafts = mutableMapOf<String, String>()
79+ val followUps = mutableMapOf<String, String>()
80+ all.forEach { (k, v) ->
81+ if (k.startsWith(FOLLOWUP_PREFIX)) followUps[k.removePrefix(FOLLOWUP_PREFIX)] = v
82+ else drafts[k] = v
83+ }
71 // Merge instead of overwriting: any in-memory writes that arrived before
84 // Merge instead of overwriting: any in-memory writes that arrived before
72 // the background load completed (e.g. a share-intent draft injected via
85 // the background load completed (e.g. a share-intent draft injected via
73 // setImmediate) take priority over the persisted snapshot so they aren't
86 // setImmediate) take priority over the persisted snapshot so they aren't
74 // clobbered.
87 // clobbered.
75− _drafts.update { current ->
88+ _drafts.update { current -> drafts.apply { putAll(current) } }
76− snapshot.toMutableMap().apply { putAll(current) }
89+ _followUps.update { current -> followUps.apply { putAll(current) } }
77− }
78 _ready.value = true
90 _ready.value = true
79 }
91 }
80 } else {
92 } else {
@@ -85,6 +97,35 @@ open class DraftStore private constructor(
85 /** Returns the draft for [sessionId], or "" if not found or still loading. */
97 /** Returns the draft for [sessionId], or "" if not found or still loading. */
86 open fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
98 open fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
87
99
100+ /** Returns the queued follow-up for [sessionId], or "" if none / still loading. */
101+ open fun getFollowUp(sessionId: String): String = _followUps.value[sessionId].orEmpty()
102+
103+ /** Persist a queued follow-up immediately (fire-and-forget, main-thread-safe).
104+ * Mirrors [flushDraft]: async apply() when prefs are open, else a background thread,
105+ * so a follow-up queued just before process death is flushed before the process exits.
106+ * A blank [text] clears the stored follow-up. */
107+ open fun flushFollowUp(sessionId: String, text: String) {
108+ _followUps.update { current ->
109+ val updated = current.toMutableMap()
110+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
111+ updated
112+ }
113+ writeAsync(FOLLOWUP_PREFIX + sessionId, text)
114+ }
115+
116+ /** Persist [key]=[text] (or remove it when blank) without blocking the main thread on
117+ * first-time disk I/O — see [flushDraft] for the prefs-not-yet-open rationale. */
118+ private fun writeAsync(key: String, text: String) {
119+ val write = {
120+ runCatching {
121+ prefs.edit().apply {
122+ if (text.isBlank()) remove(key) else putString(key, text)
123+ }.apply()
124+ }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
125+ }
126+ if (prefsInitialized.get()) write() else flushExecutor.execute { write() }
127+ }
128+
88 /** Update the in-memory draft immediately without persisting to disk.
129 /** Update the in-memory draft immediately without persisting to disk.
89 * Used when navigation must see the new value synchronously (e.g. share-intent
130 * Used when navigation must see the new value synchronously (e.g. share-intent
90 * injection before ChatScreen composes). Call [set] afterwards to persist. */
131 * injection before ChatScreen composes). Call [set] afterwards to persist. */
@@ -111,16 +152,22 @@ open class DraftStore private constructor(
111 }
152 }
112 }
153 }
113
154
114− /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
155+ /** 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. */
115 open suspend fun remove(sessionId: String) {
157 open suspend fun remove(sessionId: String) {
116 _drafts.update { current ->
158 _drafts.update { current ->
117 val updated = current.toMutableMap()
159 val updated = current.toMutableMap()
118 updated.remove(sessionId)
160 updated.remove(sessionId)
119 updated
161 updated
120 }
162 }
163+ _followUps.update { current ->
164+ val updated = current.toMutableMap()
165+ updated.remove(sessionId)
166+ updated
167+ }
121 withContext(Dispatchers.IO) {
168 withContext(Dispatchers.IO) {
122 runCatching {
169 runCatching {
123− prefs.edit().remove(sessionId).apply()
170+ prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
124 }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
171 }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
125 }
172 }
126 }
173 }
@@ -166,6 +213,12 @@ open class DraftStore private constructor(
166 }
213 }
167 }
214 }
168
215
216+ private companion object {
217+ /** 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. */
219+ const val FOLLOWUP_PREFIX = "followup"
220+ }
221+
169 /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
222 /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
170 open fun shutdown() {
223 open fun shutdown() {
171 flushExecutor.shutdown()
224 flushExecutor.shutdown()
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+6−1
@@ -261,7 +261,12 @@ open class AppContainer private constructor(
261 }
261 }
262 }
262 }
263 if (wasRunning && idleSid != _currentSession.value) {
263 if (wasRunning && idleSid != _currentSession.value) {
264+ // Fire this off the collector's coroutine: notifySessionCompleted
265+ // does a network listSessions() to resolve the title, and blocking
266+ // the shared-event collector here would stall the SharedFlow,
267+ // overflowing its DROP_OLDEST buffer and silently dropping live
268+ // parts/updates for every other subscriber (e.g. the message reducer).
264− notifySessionCompleted(idleSid)
269+ appScope.launch { notifySessionCompleted(idleSid) }
265 }
270 }
266 return@collect
271 return@collect
267 }
272 }
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+4−3
@@ -73,9 +73,10 @@ object SessionNotifications {
73 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
73 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
74 .setSmallIcon(R.drawable.ic_launcher_foreground)
74 .setSmallIcon(R.drawable.ic_launcher_foreground)
75 .setContentTitle(context.getString(R.string.notif_completed_title))
75 .setContentTitle(context.getString(R.string.notif_completed_title))
76− // Escape % characters in the title so getString formatting doesn't break
76+ // No % escaping here: getString(id, arg) inserts the argument verbatim and
77− // when a server-controlled session title contains format-specifier-like text.
77+ // never re-scans it for format specifiers, so escaping the title would show
78+ // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
78− .setContentText(context.getString(R.string.notif_completed_text, title.replace("%", "%%")))
79+ .setContentText(context.getString(R.string.notif_completed_text, title))
79 .setAutoCancel(true)
80 .setAutoCancel(true)
80 .setContentIntent(pendingIntent)
81 .setContentIntent(pendingIntent)
81 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
82 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+52−7
@@ -83,6 +83,12 @@ class ChatViewModel(
83 * debounce timer firing before the send completes. */
83 * debounce timer firing before the send completes. */
84 private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
84 private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
85
85
86+ /** The draft value this VM last persisted, so the drafts observer can tell its own
87+ * (debounced, possibly stale) echo apart from a genuine external write. Without this
88+ * a debounced persist of an older value could echo back and overwrite newer text the
89+ * user has since typed. */
90+ @Volatile private var lastPersistedDraft: String? = null
91+
86 /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
92 /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
87 * collectLatest to cancel any in-flight fetch and start a fresh one, so a
93 * collectLatest to cancel any in-flight fetch and start a fresh one, so a
88 * manual reload supersedes a stale observeCatalog fetch. */
94 * manual reload supersedes a stale observeCatalog fetch. */
@@ -221,9 +227,24 @@ class ChatViewModel(
221 // Observe the ready signal and re-seed the draft so it appears in the UI.
227 // Observe the ready signal and re-seed the draft so it appears in the UI.
222 viewModelScope.launch {
228 viewModelScope.launch {
223 container.draftStore.ready.collect { ready ->
229 container.draftStore.ready.collect { ready ->
230+ if (!ready) return@collect
224− if (ready && _draft.value.isEmpty()) {
231+ if (_draft.value.isEmpty()) {
225 _draft.value = container.draftStore.get(sessionId)
232 _draft.value = container.draftStore.get(sessionId)
226 }
233 }
234+ // Recover a follow-up queued before process death (its in-memory StateFlow
235+ // is gone). If the input is now free, drop it back there for review/send and
236+ // consume the stored copy; otherwise re-queue it so it auto-sends when the
237+ // current run next idles. Guarded on == null so it never clobbers a
238+ // follow-up the user queued after opening this session.
239+ val savedFollowUp = container.draftStore.getFollowUp(sessionId)
240+ if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
241+ if (_draft.value.isEmpty()) {
242+ _draft.value = savedFollowUp
243+ container.draftStore.flushFollowUp(sessionId, "")
244+ } else {
245+ _queuedFollowUp.value = savedFollowUp
246+ }
247+ }
227 }
248 }
228 }
249 }
229 // Observe the drafts store for external mutations to this session's draft
250 // Observe the drafts store for external mutations to this session's draft
@@ -238,7 +259,16 @@ class ChatViewModel(
238 // pre-send draft until the send resolves).
259 // pre-send draft until the send resolves).
239 viewModelScope.launch {
260 viewModelScope.launch {
240 container.draftStore.drafts.collect { drafts ->
261 container.draftStore.drafts.collect { drafts ->
262+ // Ignore emissions until the initial disk load completes: the load itself
263+ // emits the persisted snapshot, and applying it here would clobber text the
264+ // user typed during the async load (the `ready` observer above seeds an
265+ // empty draft). After ready, emissions are external writes.
266+ if (!container.draftStore.ready.value) return@collect
241 val storeValue = drafts[sessionId].orEmpty()
267 val storeValue = drafts[sessionId].orEmpty()
268+ // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
269+ // genuine external write (e.g. a two-pane share injection) should overwrite
270+ // the live draft.
271+ if (storeValue == lastPersistedDraft) return@collect
242 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
272 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
243 _draft.value = storeValue
273 _draft.value = storeValue
244 }
274 }
@@ -322,6 +352,7 @@ class ChatViewModel(
322 viewModelScope.launch {
352 viewModelScope.launch {
323 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
353 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
324 if (text.isBlank() && suppressDraftPersist.get()) return@collect
354 if (text.isBlank() && suppressDraftPersist.get()) return@collect
355+ lastPersistedDraft = text
325 runCatchingCancellable { container.draftStore.set(sessionId, text) }
356 runCatchingCancellable { container.draftStore.set(sessionId, text) }
326 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
357 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
327 }
358 }
@@ -335,7 +366,10 @@ class ChatViewModel(
335 _running.value = false
366 _running.value = false
336 _pendingPermission.value = null
367 _pendingPermission.value = null
337 _failedDraft.value = null
368 _failedDraft.value = null
338− _queuedFollowUp.value = null
369+ // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
370+ // user intent that is now persisted; wiping it on every (re)connect — which
371+ // this collector does, including transient SSE drops and the cold-start
372+ // restore path — would drop a legitimately queued/recovered follow-up.
339 _selectedAgent.value = null
373 _selectedAgent.value = null
340 _sessionTitle.value = null
374 _sessionTitle.value = null
341 try {
375 try {
@@ -346,13 +380,13 @@ class ChatViewModel(
346 // so the user's drafted-while-running message isn't lost.
380 // so the user's drafted-while-running message isn't lost.
347 val queued = _queuedFollowUp.value
381 val queued = _queuedFollowUp.value
348 if (queued != null) {
382 if (queued != null) {
349− _queuedFollowUp.value = null
383+ setQueuedFollowUp(null)
350 send(queued)
384 send(queued)
351 }
385 }
352 }
386 }
353 if (SessionRepository.isError(event, sessionId)) {
387 if (SessionRepository.isError(event, sessionId)) {
354 _running.value = false
388 _running.value = false
355− _queuedFollowUp.value = null
389+ setQueuedFollowUp(null)
356 _errorEvents.tryEmit(container.string(R.string.error_agent_reported))
390 _errorEvents.tryEmit(container.string(R.string.error_agent_reported))
357 }
391 }
358 when (event) {
392 when (event) {
@@ -492,7 +526,10 @@ class ChatViewModel(
492 // typed a new one while the send was in flight. Otherwise clearing the
526 // typed a new one while the send was in flight. Otherwise clearing the
493 // store echoes back through the draft observer and wipes the in-progress
527 // store echoes back through the draft observer and wipes the in-progress
494 // text (data loss). Mirrors the guard on the failure path above.
528 // text (data loss). Mirrors the guard on the failure path above.
529+ if (_draft.value.isBlank()) {
530+ lastPersistedDraft = ""
495− if (_draft.value.isBlank()) container.draftStore.set(sessionId, "")
531+ container.draftStore.set(sessionId, "")
532+ }
496 // Don't reset _running here: the agent continues streaming via SSE.
533 // Don't reset _running here: the agent continues streaming via SSE.
497 // _running is cleared on SessionIdle/SessionError (see event collector)
534 // _running is cleared on SessionIdle/SessionError (see event collector)
498 // or when the SSE stream drops (see connection state watcher below).
535 // or when the SSE stream drops (see connection state watcher below).
@@ -520,13 +557,21 @@ class ChatViewModel(
520 */
557 */
521 fun queueFollowUp(text: String) {
558 fun queueFollowUp(text: String) {
522 val trimmed = text.trim()
559 val trimmed = text.trim()
523− _queuedFollowUp.value = trimmed.takeIf { it.isNotEmpty() }
560+ setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
524 // The text now lives in the queued chip, so clear the input field just as a
561 // The text now lives in the queued chip, so clear the input field just as a
525 // normal send would. No-op for the cancel case (blank text), which must leave
562 // normal send would. No-op for the cancel case (blank text), which must leave
526 // whatever the user has since typed untouched.
563 // whatever the user has since typed untouched.
527 if (trimmed.isNotEmpty()) _draft.value = ""
564 if (trimmed.isNotEmpty()) _draft.value = ""
528 }
565 }
529
566
567+ /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
568+ * queued mid-run survives process death (recovered on the next open of this session).
569+ * Passing null / blank clears both the in-memory value and the persisted copy. */
570+ private fun setQueuedFollowUp(text: String?) {
571+ _queuedFollowUp.value = text
572+ container.draftStore.flushFollowUp(sessionId, text.orEmpty())
573+ }
574+
530 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
575 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
531 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
576 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
532 * refreshMessages may not visibly change [connectionState] (it's already
577 * refreshMessages may not visibly change [connectionState] (it's already
@@ -570,7 +615,7 @@ class ChatViewModel(
570 val conn = connection ?: return
615 val conn = connection ?: return
571 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
616 // 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.
617 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
573− _queuedFollowUp.value = null
618+ setQueuedFollowUp(null)
574 if (!_aborting.compareAndSet(false, true)) return
619 if (!_aborting.compareAndSet(false, true)) return
575 viewModelScope.launch {
620 viewModelScope.launch {
576 try {
621 try {
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+8−6
@@ -111,7 +111,7 @@ fun PartView(
111 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
111 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
112 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
112 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
113 }
113 }
114− is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
114+ is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
115 is ToolPart -> ToolCallView(part, modifier)
115 is ToolPart -> ToolCallView(part, modifier)
116 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
116 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
117 RemoteImage(part, imageContext, modifier)
117 RemoteImage(part, imageContext, modifier)
@@ -149,13 +149,15 @@ private fun UnknownPartNote(modifier: Modifier) {
149}
149}
150
150
151@Composable
151@Composable
152−private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier) {
152+private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modifier: Modifier) {
153 if (text.isBlank()) return
153 if (text.isBlank()) return
154 val context = LocalContext.current
154 val context = LocalContext.current
155− // Key the expanded state on a content prefix so a new ReasoningPart (e.g. a second
155+ // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
156− // reasoning block added during streaming) doesn't inherit the positional saveable
156+ // inherit the positional saveable key of a prior block. A content-prefix key would
157− // key of a prior block and start in the wrong expanded state.
157+ // change on nearly every token while streaming and snap the block back to collapsed;
158− var expanded by rememberSaveable(text.take(64)) { mutableStateOf(false) }
158+ // fall back to a content prefix only when the part has no id.
159+ val saveableKey = keyId.ifBlank { text.take(64) }
160+ var expanded by rememberSaveable(saveableKey) { mutableStateOf(false) }
159 val expandedState = stringResource(R.string.state_expanded)
161 val expandedState = stringResource(R.string.state_expanded)
160 val collapsedState = stringResource(R.string.state_collapsed)
162 val collapsedState = stringResource(R.string.state_collapsed)
161 val thinkingLabel = stringResource(R.string.thinking)
163 val thinkingLabel = stringResource(R.string.thinking)
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+6−4
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.padding
7import androidx.compose.foundation.layout.padding
8−import androidx.activity.compose.BackHandler
9import androidx.compose.material.icons.Icons
8import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.filled.Shield
9import androidx.compose.material.icons.filled.Shield
11import androidx.compose.material3.AlertDialog
10import androidx.compose.material3.AlertDialog
@@ -50,10 +49,13 @@ fun PermissionDialog(
50 onRespond(response)
49 onRespond(response)
51 }
50 }
52 }
51 }
53− // Intercept back so the dialog can't be dismissed without an explicit choice.
54− BackHandler { respond(PermissionResponse.REJECT) }
55 AlertDialog(
52 AlertDialog(
56− onDismissRequest = { /* require an explicit choice */ },
53+ // Back press routes here (dismissOnBackPress defaults true) and is treated as an
54+ // explicit reject — the safe default. Tap-outside is disabled below so it can't
55+ // dismiss silently. A host-composition BackHandler doesn't work: AlertDialog
56+ // renders in its own window whose back dispatcher never reaches the host.
57+ onDismissRequest = { respond(PermissionResponse.REJECT) },
58+ properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
57 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
59 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
58 title = { Text(permission.title ?: stringResource(R.string.permission_title)) },
60 title = { Text(permission.title ?: stringResource(R.string.permission_title)) },
59 text = {
61 text = {
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+6−1
@@ -61,7 +61,12 @@ private fun isDiffFileHeader(raw: String): Boolean {
61 raw.startsWith("+++ ") -> raw.substring(4)
61 raw.startsWith("+++ ") -> raw.substring(4)
62 else -> return false
62 else -> return false
63 }
63 }
64+ // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a
65+ // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids
66+ // misclassifying a removed content line like "-- /usr/local/foo" (on the wire
67+ // "--- /usr/local/foo"), whose rest starts with "/", as a file header.
64− return rest.startsWith("a/") || rest.startsWith("b/") || rest.startsWith("/")
68+ return rest.startsWith("a/") || rest.startsWith("b/") ||
69+ rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ")
65}
70}
66
71
67/** Parse a unified diff string into typed [DiffLine]s. */
72/** Parse a unified diff string into typed [DiffLine]s. */
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+13−6
@@ -69,15 +69,22 @@ private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
69 return if (url.startsWith("http://") || url.startsWith("https://")) {
69 return if (url.startsWith("http://") || url.startsWith("https://")) {
70 url
70 url
71 } else {
71 } else {
72− // Resolve relative URL against the base. Collapse any ../ segments to
72+ // Resolve the URL (relative or server-absolute like "/media/x.png") against the
73− // prevent path traversal from escaping the server's base path.
73+ // base, collapsing any ../ segments.
74 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
74 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
75+ val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
75 val resolved = runCatching {
76 val resolved = runCatching {
76− java.net.URI(base).resolve(url).normalize().toString()
77+ java.net.URI(base).resolve(url).normalize()
77 }.getOrElse { return null }
78 }.getOrElse { return null }
78− // Guard: if normalization escaped the base path, return null (don't
79+ // Guard on same origin (scheme + host + port), not base-path prefix: the real
79− // attempt to load the base URL itself as an image — it's not one).
80+ // risk is redirecting the request (with its Basic auth) to another host. A
80− if (!resolved.startsWith(base)) null else resolved
81+ // server-absolute path resolves to a different base *path* but the same origin,
82+ // so it must still be allowed — a prefix check wrongly rejected those.
83+ val sameOrigin = resolved.host != null &&
84+ resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
85+ resolved.host.equals(baseUri.host, ignoreCase = true) &&
86+ resolved.port == baseUri.port
87+ if (sameOrigin) resolved.toString() else null
81 }
88 }
82}
89}
83
90
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+12−6
@@ -98,15 +98,21 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
98 }
98 }
99 }
99 }
100
100
101− /** Re-open the current directory (manual refresh). Used by pull-to-refresh. */
101+ /** Re-run the current view (manual refresh). Used by pull-to-refresh. */
102 fun refresh() {
102 fun refresh() {
103 _refreshing.value = true
103 _refreshing.value = true
104+ val searching = _state.value.isSearching
105+ if (searching) {
106+ // Re-run the active search rather than open(), which would reset query/results
107+ // and drop the user back into the directory listing — discarding their search.
108+ setQuery(_state.value.query)
109+ } else {
104− open(_state.value.path)
110+ open(_state.value.path)
111+ }
105 viewModelScope.launch {
112 viewModelScope.launch {
106− // Clear the spinner once the open() launch settles. open() sets loading=true
113+ // Clear the spinner once the in-flight load settles by joining whichever job
107− // synchronously and flips it false in its own coroutine; wait one frame cycle.
114+ // the refresh kicked off (search or directory open).
108− // Simpler: clear after the openJob completes by joining it.
109− openJob?.join()
115+ (if (searching) searchJob else openJob)?.join()
110 _refreshing.value = false
116 _refreshing.value = false
111 }
117 }
112 }
118 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+7−1
@@ -62,6 +62,7 @@ import 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
64import kotlinx.coroutines.coroutineScope
64import kotlinx.coroutines.coroutineScope
65+import kotlinx.coroutines.flow.collectLatest
65import kotlinx.coroutines.delay
66import kotlinx.coroutines.delay
66import kotlinx.coroutines.launch
67import kotlinx.coroutines.launch
67import soy.iko.opencode.data.model.ServerProfile
68import soy.iko.opencode.data.model.ServerProfile
@@ -114,7 +115,12 @@ fun ServerListScreen(
114 // (~10s) outlasted the window, leaving the button on screen but dead for its
115 // (~10s) outlasted the window, leaving the button on screen but dead for its
115 // second half. Mirrors DiagnosticsScreen's undo pattern.
116 // second half. Mirrors DiagnosticsScreen's undo pattern.
116 LaunchedEffect(Unit) {
117 LaunchedEffect(Unit) {
118+ // collectLatest (not collect): a serialized collect queues each snackbar behind
119+ // the previous one's full window, so under rapid deletes a later profile's VM
120+ // delete timer (started at emit time) fires before its snackbar is ever shown —
121+ // a dead Undo button. collectLatest instead cancels the current snackbar and
122+ // shows the newest immediately, keeping its Undo window aligned with its timer.
117− vm.undoEvents.collect { profileId ->
123+ vm.undoEvents.collectLatest { profileId ->
118 coroutineScope {
124 coroutineScope {
119 val dismisser = launch {
125 val dismisser = launch {
120 delay(NetworkConfig.undoServerDeleteDelayMs)
126 delay(NetworkConfig.undoServerDeleteDelayMs)
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+7−1
@@ -78,6 +78,7 @@ import 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
80import kotlinx.coroutines.coroutineScope
80import kotlinx.coroutines.coroutineScope
81+import kotlinx.coroutines.flow.collectLatest
81import kotlinx.coroutines.delay
82import kotlinx.coroutines.delay
82import kotlinx.coroutines.launch
83import kotlinx.coroutines.launch
83import soy.iko.opencode.data.model.ServerProfile
84import soy.iko.opencode.data.model.ServerProfile
@@ -146,7 +147,12 @@ fun SessionListScreen(
146 // SnackbarDuration.Long (~10s) outlasted the window, leaving the button on screen
147 // SnackbarDuration.Long (~10s) outlasted the window, leaving the button on screen
147 // but dead for its second half. Mirrors DiagnosticsScreen's undo pattern.
148 // but dead for its second half. Mirrors DiagnosticsScreen's undo pattern.
148 LaunchedEffect(Unit) {
149 LaunchedEffect(Unit) {
150+ // collectLatest (not collect): a serialized collect queues each snackbar behind
151+ // the previous one's full window, so under rapid deletes a later session's VM
152+ // delete timer (started at emit time) fires before its snackbar is ever shown —
153+ // a dead Undo button. collectLatest instead cancels the current snackbar and
154+ // shows the newest immediately, keeping its Undo window aligned with its timer.
149− vm.undoEvents.collect { sessionId ->
155+ vm.undoEvents.collectLatest { sessionId ->
150 coroutineScope {
156 coroutineScope {
151 val dismisser = launch {
157 val dismisser = launch {
152 delay(NetworkConfig.undoDeleteDelayMs)
158 delay(NetworkConfig.undoDeleteDelayMs)
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+14
@@ -192,6 +192,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
192 when (event) {
192 when (event) {
193 is SessionUpdated -> {
193 is SessionUpdated -> {
194 val session = event.properties.info
194 val session = event.properties.info
195+ // Ignore updates for a session the user just deleted (still
196+ // within its undo window): the server hasn't deleted it yet
197+ // and can legitimately emit updates, but buffering them here
198+ // would resurrect the optimistically-hidden row mid-undo.
199+ if (pendingDeletes.containsKey(session.id)) return@collect
195 pendingSessionUpdates[session.id] = session
200 pendingSessionUpdates[session.id] = session
196 if (sessionUpdateJob?.isActive != true) {
201 if (sessionUpdateJob?.isActive != true) {
197 sessionUpdateJob = viewModelScope.launch {
202 sessionUpdateJob = viewModelScope.launch {
@@ -204,6 +209,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
204 val id = event.properties.info?.id ?: event.properties.sessionID
209 val id = event.properties.info?.id ?: event.properties.sessionID
205 if (id != null) {
210 if (id != null) {
206 synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
211 synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
212+ // Drop any buffered update for this id so a pending flush
213+ // can't re-add the just-deleted session to the list.
214+ pendingSessionUpdates.remove(id)
207 container.draftStore.remove(id)
215 container.draftStore.remove(id)
208 _state.update { s ->
216 _state.update { s ->
209 s.copy(
217 s.copy(
@@ -239,6 +247,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
239 val byId = s.sessions.associateBy { it.id }.toMutableMap()
247 val byId = s.sessions.associateBy { it.id }.toMutableMap()
240 var anyChanged = false
248 var anyChanged = false
241 batch.forEach { (id, session) ->
249 batch.forEach { (id, session) ->
250+ // 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.
252+ if (id in pendingDeletes) return@forEach
242 if (byId[id] != session) { byId[id] = session; anyChanged = true }
253 if (byId[id] != session) { byId[id] = session; anyChanged = true }
243 }
254 }
244 // No-op update: skip the rebuild entirely so the StateFlow doesn't emit a
255 // No-op update: skip the rebuild entirely so the StateFlow doesn't emit a
@@ -399,6 +410,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
399 }
410 }
400 // Hold the pending delete so the snackbar can undo it.
411 // Hold the pending delete so the snackbar can undo it.
401 pendingDeletes[session.id] = session
412 pendingDeletes[session.id] = session
413+ // Drop any already-buffered update for this session so a pending flush during the
414+ // undo window can't re-add the row we just optimistically hid.
415+ pendingSessionUpdates.remove(session.id)
402 _undoEvents.tryEmit(session.id)
416 _undoEvents.tryEmit(session.id)
403 viewModelScope.launch {
417 viewModelScope.launch {
404 delay(NetworkConfig.undoDeleteDelayMs)
418 delay(NetworkConfig.undoDeleteDelayMs)
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+4−1
@@ -229,7 +229,10 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
229 var loadFailed by remember(reportName) { mutableStateOf(false) }
229 var loadFailed by remember(reportName) { mutableStateOf(false) }
230 LaunchedEffect(reportName) {
230 LaunchedEffect(reportName) {
231 val loaded = withContext(Dispatchers.IO) {
231 val loaded = withContext(Dispatchers.IO) {
232+ // runCatchingCancellable (not runCatching) so dismissing the dialog or
233+ // switching reports mid-read lets the CancellationException propagate
234+ // instead of being swallowed and setting state on a torn-down effect.
232− runCatching { logger.readReport(reportName) }.getOrNull()
235+ runCatchingCancellable { logger.readReport(reportName) }.getOrNull()
233 }
236 }
234 if (loaded != null) reportContent = loaded else loadFailed = true
237 if (loaded != null) reportContent = loaded else loadFailed = true
235 }
238 }