git.iko.soy/opencode-android
on master help sign in
Fix 15 bugs across UI, data, notification, and service layers
ae3e393iko
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+56−69
@@ -6,6 +6,7 @@ import kotlinx.serialization.decodeFromString
6import kotlinx.serialization.encodeToString
6import kotlinx.serialization.encodeToString
7import kotlinx.serialization.json.Json
7import kotlinx.serialization.json.Json
8import soy.iko.opencode.data.model.ServerProfile
8import soy.iko.opencode.data.model.ServerProfile
9+import soy.iko.opencode.util.runCatchingCancellable
9
10
10/** A server entry in a backup. [password] is present only when the user opted to include it. */
11/** A server entry in a backup. [password] is present only when the user opted to include it. */
11@Serializable
12@Serializable
@@ -137,78 +138,64 @@ class BackupManager(
137 require(data.version in supportedVersions) {
138 require(data.version in supportedVersions) {
138 "Unsupported backup version ${data.version}; supported: $supportedVersions"
139 "Unsupported backup version ${data.version}; supported: $supportedVersions"
139 }
140 }
141+ var hadError = false
140 for (s in data.servers) {
142 for (s in data.servers) {
143+ runCatchingCancellable {
141− val username = s.username?.takeIf { it.isNotBlank() }
144+ val username = s.username?.takeIf { it.isNotBlank() }
142− val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
145+ val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
143− // A backup exported *without* passwords carries null. This is an upsert, so don't let
144− // that null wipe a password already stored for this server id — preserve the existing
145− // secret when the backup didn't include one (only the backup explicitly carrying a
146− // password should overwrite it). Re-importing a password-less backup would otherwise
147− // silently break auth for every matching server.
148− val password = incomingPassword ?: username?.let {
146+ val password = incomingPassword ?: username?.let {
149− profileStore.resolve(
147+ profileStore.resolve(
150− ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
148+ ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
151− ).password
149+ ).password
152− }
150+ }
153− profileStore.save(
151+ profileStore.save(
154− ServerProfile(
152+ ServerProfile(
155− id = s.id,
153+ id = s.id,
156− label = s.label,
154+ label = s.label,
157− baseUrl = s.baseUrl,
155+ baseUrl = s.baseUrl,
158− username = username,
156+ username = username,
159− password = password,
157+ password = password,
160− lastUsed = 0,
158+ lastUsed = 0,
161− requireHttps = s.requireHttps,
159+ requireHttps = s.requireHttps,
162− certPin = s.certPin?.takeIf { it.isNotBlank() },
160+ certPin = s.certPin?.takeIf { it.isNotBlank() },
161+ ),
163− ),
162+ )
164− )
163+ }.onFailure { hadError = true }
165 }
164 }
166 data.settings?.let { settings ->
165 data.settings?.let { settings ->
167− // Parse the enum defensively (a bad name from an edited backup shouldn't abort the
166+ runCatchingCancellable {
168− // import) WITHOUT wrapping the suspend setThemeMode call — wrapping it would swallow
169− // a CancellationException and break structured concurrency.
170− ThemeMode.entries.find { it.name == settings.themeMode }?.let { settingsStore.setThemeMode(it) }
167+ ThemeMode.entries.find { it.name == settings.themeMode }?.let { settingsStore.setThemeMode(it) }
171− settingsStore.setDynamicColor(settings.dynamicColor)
168+ settingsStore.setDynamicColor(settings.dynamicColor)
172− settingsStore.setSendOnEnter(settings.sendOnEnter)
169+ settingsStore.setSendOnEnter(settings.sendOnEnter)
173− settingsStore.setAppLock(settings.appLock)
170+ settingsStore.setAppLock(settings.appLock)
174− settingsStore.setAppLockReLockSeconds(settings.appLockReLockSeconds)
171+ settingsStore.setAppLockReLockSeconds(settings.appLockReLockSeconds)
175− settingsStore.setChatTextScale(settings.chatTextScale)
172+ settingsStore.setChatTextScale(settings.chatTextScale)
176− settingsStore.setCodeWrap(settings.codeWrap)
173+ settingsStore.setCodeWrap(settings.codeWrap)
177− // sessionSortMode is written below, AFTER validation against the known enum
178− // entries — do NOT write it unconditionally here, or a garbage string from a
179− // hand-edited/malicious backup would persist with no in-app reset path (the
180− // validated write below only fires when the enum is found, so an earlier
181− // unconditional write would leave the corrupt value in place when it's not).
182− settingsStore.setSessionSortDescending(settings.sessionSortDescending)
174+ settingsStore.setSessionSortDescending(settings.sessionSortDescending)
183− settingsStore.setSessionShowArchived(settings.sessionShowArchived)
175+ settingsStore.setSessionShowArchived(settings.sessionShowArchived)
184− settingsStore.setPreferredModelId(settings.preferredModelId)
176+ settingsStore.setPreferredModelId(settings.preferredModelId)
185− settingsStore.setPreferredAgentName(settings.preferredAgentName)
177+ settingsStore.setPreferredAgentName(settings.preferredAgentName)
186− settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
178+ settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
187− settingsStore.setHapticsEnabled(settings.hapticsEnabled)
179+ settingsStore.setHapticsEnabled(settings.hapticsEnabled)
188− settingsStore.setReducedMotion(settings.reducedMotion)
180+ settingsStore.setReducedMotion(settings.reducedMotion)
189− // Validate the sort mode against the known enum entries before persisting, so a
190− // garbage string from a hand-edited/malicious backup can't produce a broken sort
191− // with no in-app reset path (the picker writes known values, but a corrupt one
192− // would persist and leave the list in an undefined order). Mirrors the defensive
193− // enum parsing used for themeMode and swipe actions above.
194− soy.iko.opencode.ui.session.SessionSortMode.entries
181+ soy.iko.opencode.ui.session.SessionSortMode.entries
195− .find { it.name == settings.sessionSortMode }
182+ .find { it.name == settings.sessionSortMode }
196− ?.let { settingsStore.setSessionSortMode(it.name) }
183+ ?.let { settingsStore.setSessionSortMode(it.name) }
197− // Validate the language override is a plausible ISO 639-1 code (2–3 lowercase
198− // letters, optionally with a region/script subtag) before persisting, so a bad
199− // string doesn't reach AppCompat's per-app language API. Empty (system default)
200− // is always allowed.
201− val lang = settings.languageOverride
184+ val lang = settings.languageOverride
202− if (lang.isEmpty() || lang.matches(Regex("^[a-z]{2,3}(-[A-Za-z0-9]+)*$"))) {
185+ if (lang.isEmpty() || lang.matches(Regex("^[a-z]{2,3}(-[A-Za-z0-9]+)*$"))) {
203− settingsStore.setLanguageOverride(lang)
186+ settingsStore.setLanguageOverride(lang)
204− }
187+ }
205− settingsStore.setNotifRunComplete(settings.notifRunComplete)
188+ settingsStore.setNotifRunComplete(settings.notifRunComplete)
206− settingsStore.setNotifPermission(settings.notifPermission)
189+ settingsStore.setNotifPermission(settings.notifPermission)
207− settingsStore.setNotifError(settings.notifError)
190+ settingsStore.setNotifError(settings.notifError)
208− SwipeAction.entries.find { it.name == settings.swipeLeftAction }?.let { settingsStore.setSwipeLeftAction(it) }
191+ SwipeAction.entries.find { it.name == settings.swipeLeftAction }?.let { settingsStore.setSwipeLeftAction(it) }
209− SwipeAction.entries.find { it.name == settings.swipeRightAction }?.let { settingsStore.setSwipeRightAction(it) }
192+ SwipeAction.entries.find { it.name == settings.swipeRightAction }?.let { settingsStore.setSwipeRightAction(it) }
193+ }.onFailure { hadError = true }
210 }
194 }
195+ runCatchingCancellable {
211− data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
196+ data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
212− data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
197+ data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
198+ }.onFailure { hadError = true }
199+ if (hadError) throw IllegalStateException("Backup import completed with errors")
213 }
200 }
214}
201}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+19−2
@@ -92,7 +92,16 @@ open class OutboxStore private constructor(
92 // offline-composed messages.
92 // offline-composed messages.
93 val diskList = withContext(Dispatchers.IO) {
93 val diskList = withContext(Dispatchers.IO) {
94 runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
94 runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
95− }.getOrElse { return false }
95+ }.getOrElse {
96+ // A corrupt or unreadable outbox.json would permanently brick the queue: every
97+ // subsequent mutate retries the read, fails, and skips its persist. Delete the
98+ // corrupt file so the queue can recover — the data is already unreadable, and
99+ // preserving any in-memory messages enqueued since the failure keeps new work.
100+ Log.w("OutboxStore", "Corrupt outbox.json; deleting and starting fresh", it)
101+ runCatchingCancellable { f.delete() }
102+ loaded = true
103+ return true
104+ }
96 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
105 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
97 // the in-memory version on conflict (it's newer).
106 // the in-memory version on conflict (it's newer).
98 if (_messages.value.isEmpty()) {
107 if (_messages.value.isEmpty()) {
@@ -163,7 +172,15 @@ open class OutboxStore private constructor(
163 try {
172 try {
164 tmp.writeText(encoded)
173 tmp.writeText(encoded)
165 if (!tmp.renameTo(f)) {
174 if (!tmp.renameTo(f)) {
175+ try {
176+ java.nio.file.Files.move(
177+ tmp.toPath(), f.toPath(),
178+ java.nio.file.StandardCopyOption.REPLACE_EXISTING,
179+ )
180+ } catch (e: Exception) {
181+ Log.w("OutboxStore", "NIO move failed; falling back to direct write", e)
166− f.writeText(encoded)
182+ f.writeText(encoded)
183+ }
167 }
184 }
168 } finally {
185 } finally {
169 tmp.delete()
186 tmp.delete()
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+7−4
@@ -892,7 +892,10 @@ open class AppContainer private constructor(
892 // message.updated, treating it as live would re-add the session to activeRuns with no
892 // message.updated, treating it as live would re-add the session to activeRuns with no
893 // following SessionIdle to clear it — pinning anyRunActive true indefinitely when such
893 // following SessionIdle to clear it — pinning anyRunActive true indefinitely when such
894 // a part is replayed/reordered after the run already went idle.
894 // a part is replayed/reordered after the run already went idle.
895+ val part = when (event) {
895− val part = (event as MessagePartUpdated).properties.part
896+ is MessagePartUpdated -> event.properties.part
897+ else -> return false
898+ }
896 return part !is StepFinishPart
899 return part !is StepFinishPart
897 }
900 }
898
901
@@ -1260,7 +1263,7 @@ open class AppContainer private constructor(
1260 _runProgressText.value = null
1263 _runProgressText.value = null
1261 }
1264 }
1262 if (previousProfileId != profile.id) {
1265 if (previousProfileId != profile.id) {
1263− _unread.value = emptyMap()
1266+ _unread.update { emptyMap() }
1264 unreadMessageIds.clear()
1267 unreadMessageIds.clear()
1265 }
1268 }
1266 val now = System.currentTimeMillis()
1269 val now = System.currentTimeMillis()
@@ -1307,7 +1310,7 @@ open class AppContainer private constructor(
1307 // "Step 2 of 5" exposed in the public StateFlow with no run actually active.
1310 // "Step 2 of 5" exposed in the public StateFlow with no run actually active.
1308 _runProgressText.value = null
1311 _runProgressText.value = null
1309 }
1312 }
1310− _unread.value = emptyMap()
1313+ _unread.update { emptyMap() }
1311 unreadMessageIds.clear()
1314 unreadMessageIds.clear()
1312 }
1315 }
1313
1316
@@ -1538,7 +1541,7 @@ open class AppContainer private constructor(
1538 // immediately on cold start (before any session list composition).
1541 // immediately on cold start (before any session list composition).
1539 appScope.launch {
1542 appScope.launch {
1540 runCatchingCancellable {
1543 runCatchingCancellable {
1541− sessionPrefsStore.muted.collect { ids -> _mutedSessions.value = ids }
1544+ sessionPrefsStore.muted.collect { ids -> _mutedSessions.update { ids } }
1542 }
1545 }
1543 }
1546 }
1544 // Flush right after load() populates the queue. observeOutbox()'s combine is keyed
1547 // Flush right after load() populates the queue. observeOutbox()'s combine is keyed
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+11−2
@@ -14,6 +14,7 @@ import android.annotation.SuppressLint
14import androidx.core.app.NotificationCompat
14import androidx.core.app.NotificationCompat
15import androidx.core.app.NotificationManagerCompat
15import androidx.core.app.NotificationManagerCompat
16import androidx.core.app.ServiceCompat
16import androidx.core.app.ServiceCompat
17+import androidx.core.content.ContextCompat
17import soy.iko.opencode.MainActivity
18import soy.iko.opencode.MainActivity
18import soy.iko.opencode.R
19import soy.iko.opencode.R
19import java.security.MessageDigest
20import java.security.MessageDigest
@@ -63,6 +64,7 @@ class RunForegroundService : Service() {
63 @android.annotation.SuppressLint("MissingPermission")
64 @android.annotation.SuppressLint("MissingPermission")
64 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
65 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
65 instance = this
66 instance = this
67+ runCatching { NotificationManagerCompat.from(this).cancel(NOTIF_TIMEOUT_ID) }
66 // The session title (when a single run is active) is passed via the intent extra so
68 // The session title (when a single run is active) is passed via the intent extra so
67 // the notification can identify which session is running, instead of a generic
69 // the notification can identify which session is running, instead of a generic
68 // "Agent is working…". Null/blank falls back to the generic title.
70 // "Agent is working…". Null/blank falls back to the generic title.
@@ -243,6 +245,10 @@ class RunForegroundService : Service() {
243 * timed out by the system — the run continues but may be killed by Doze without warning. */
245 * timed out by the system — the run continues but may be killed by Doze without warning. */
244 @SuppressLint("MissingPermission")
246 @SuppressLint("MissingPermission")
245 private fun postTimeoutNotification() {
247 private fun postTimeoutNotification() {
248+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
249+ ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) !=
250+ android.content.pm.PackageManager.PERMISSION_GRANTED
251+ ) return
246 runCatching {
252 runCatching {
247 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
253 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
248 .setSmallIcon(R.drawable.ic_stat_notify)
254 .setSmallIcon(R.drawable.ic_stat_notify)
@@ -260,13 +266,16 @@ class RunForegroundService : Service() {
260 override fun onDestroy() {
266 override fun onDestroy() {
261 // Safety net: if the process is being killed while a run is in progress,
267 // Safety net: if the process is being killed while a run is in progress,
262 // ensure the notification is removed rather than lingering.
268 // ensure the notification is removed rather than lingering.
269+ // Clear isInForeground BEFORE stopForeground so a concurrent updateProgress (called
270+ // from the companion start() on Dispatchers.IO) returns early before re-posting the
271+ // notification that stopForeground is about to remove.
272+ isInForeground = false
263 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
273 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
264− // Clear the run-start / foreground / instance state so the next run's chronometer
274+ // Clear the run-start / instance state so the next run's chronometer
265 // begins fresh and startForeground is re-asserted on the first intent of the next
275 // begins fresh and startForeground is re-asserted on the first intent of the next
266 // run. These are instance fields, so a recreated service resets them by construction
276 // run. These are instance fields, so a recreated service resets them by construction
267 // — but clearing here also covers the same-instance restart case.
277 // — but clearing here also covers the same-instance restart case.
268 runStartMillis = 0L
278 runStartMillis = 0L
269− isInForeground = false
270 lastTitle = null
279 lastTitle = null
271 lastSessionId = null
280 lastSessionId = null
272 lastProfileId = null
281 lastProfileId = null
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+10
@@ -200,6 +200,7 @@ object SessionNotifications {
200 .setContentText(context.getString(R.string.app_name))
200 .setContentText(context.getString(R.string.app_name))
201 .setContentIntent(contentIntent)
201 .setContentIntent(contentIntent)
202 .setAutoCancel(true)
202 .setAutoCancel(true)
203+ .setOnlyAlertOnce(true)
203 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
204 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
204 .setGroup(GROUP_COMPLETED)
205 .setGroup(GROUP_COMPLETED)
205 .setGroupSummary(true)
206 .setGroupSummary(true)
@@ -560,10 +561,19 @@ object SessionNotifications {
560 @SuppressLint("MissingPermission")
561 @SuppressLint("MissingPermission")
561 private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
562 private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
562 if (!canPost(context)) return
563 if (!canPost(context)) return
564+ val openIntent = Intent(context, MainActivity::class.java).apply {
565+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
566+ }
567+ val contentIntent = PendingIntent.getActivity(
568+ context, 0, openIntent,
569+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
570+ )
563 val summary = NotificationCompat.Builder(context, channelId)
571 val summary = NotificationCompat.Builder(context, channelId)
564 .setSmallIcon(R.drawable.ic_stat_notify)
572 .setSmallIcon(R.drawable.ic_stat_notify)
565 .setContentTitle(context.getString(R.string.app_name))
573 .setContentTitle(context.getString(R.string.app_name))
574+ .setContentIntent(contentIntent)
566 .setAutoCancel(true)
575 .setAutoCancel(true)
576+ .setOnlyAlertOnce(true)
567 .setGroup(group)
577 .setGroup(group)
568 .setGroupSummary(true)
578 .setGroupSummary(true)
569 .build()
579 .build()
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+15−3
@@ -315,6 +315,7 @@ fun ChatScreen(
315 // the list to each one, mirroring desktop chat apps (the bare filtered list previously
315 // the list to each one, mirroring desktop chat apps (the bare filtered list previously
316 // required reading each message to find the term).
316 // required reading each message to find the term).
317 var searchPos by rememberSaveable { mutableIntStateOf(0) }
317 var searchPos by rememberSaveable { mutableIntStateOf(0) }
318+ var searchFiltering by remember { mutableStateOf(false) }
318 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
319 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
319 // a confirmation dialog (from both the overflow menu and the command palette).
320 // a confirmation dialog (from both the overflow menu and the command palette).
320 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
321 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
@@ -1035,6 +1036,7 @@ fun ChatScreen(
1035 }
1036 }
1036 }
1037 }
1037 }
1038 }
1039+ searchFiltering = false
1038 }
1040 }
1039 filtered
1041 filtered
1040 }
1042 }
@@ -1045,10 +1047,11 @@ fun ChatScreen(
1045 // the last valid index (keyed on size so a content refresh that shrinks the matches
1047 // the last valid index (keyed on size so a content refresh that shrinks the matches
1046 // without a query change doesn't leave searchPos out of range).
1048 // without a query change doesn't leave searchPos out of range).
1047 LaunchedEffect(searchQuery) {
1049 LaunchedEffect(searchQuery) {
1050+ if (searchQuery.isNotEmpty()) searchFiltering = true
1048 searchPos = 0
1051 searchPos = 0
1049 }
1052 }
1050 LaunchedEffect(searchMessages.size) {
1053 LaunchedEffect(searchMessages.size) {
1051− if (searchPos >= searchMessages.size) searchPos = 0
1054+ if (searchPos >= searchMessages.size) searchPos = (searchMessages.size - 1).coerceAtLeast(0)
1052 }
1055 }
1053 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
1056 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
1054 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
1057 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
@@ -1177,7 +1180,7 @@ fun ChatScreen(
1177 // @Composable property, so we read it inside a snapshotFlow registered in the composable
1180 // @Composable property, so we read it inside a snapshotFlow registered in the composable
1178 // scope; the flow re-emits whenever the IME inset changes (open/close transitions).
1181 // scope; the flow re-emits whenever the IME inset changes (open/close transitions).
1179 val density = androidx.compose.ui.platform.LocalDensity.current
1182 val density = androidx.compose.ui.platform.LocalDensity.current
1180− var wasImeOpen by remember { mutableStateOf(false) }
1183+ var wasImeOpen by rememberSaveable { mutableStateOf(false) }
1181 val imeBottomPx = WindowInsets.ime.getBottom(density)
1184 val imeBottomPx = WindowInsets.ime.getBottom(density)
1182 androidx.compose.runtime.LaunchedEffect(imeBottomPx, listItems.isNotEmpty(), isPinnedToBottom) {
1185 androidx.compose.runtime.LaunchedEffect(imeBottomPx, listItems.isNotEmpty(), isPinnedToBottom) {
1183 if (listItems.isEmpty()) return@LaunchedEffect
1186 if (listItems.isEmpty()) return@LaunchedEffect
@@ -1196,6 +1199,7 @@ fun ChatScreen(
1196 LaunchedEffect(Unit) {
1199 LaunchedEffect(Unit) {
1197 var prevSize = 0
1200 var prevSize = 0
1198 var prevLen = 0
1201 var prevLen = 0
1202+ var initialized = false
1199 snapshotFlow {
1203 snapshotFlow {
1200 // Track list size + the streaming length of the last part so we auto-scroll
1204 // Track list size + the streaming length of the last part so we auto-scroll
1201 // as content arrives, plus the pinned flag. A small data class with primitive
1205 // as content arrives, plus the pinned flag. A small data class with primitive
@@ -1207,6 +1211,12 @@ fun ChatScreen(
1207 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
1211 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
1208 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
1212 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
1209 }.collect { signal ->
1213 }.collect { signal ->
1214+ if (!initialized) {
1215+ prevSize = signal.size
1216+ prevLen = signal.lastTextLength
1217+ initialized = true
1218+ return@collect
1219+ }
1210 if (signal.pinned) {
1220 if (signal.pinned) {
1211 // Back at the bottom: clear the new-content badge.
1221 // Back at the bottom: clear the new-content badge.
1212 newContentCount = 0
1222 newContentCount = 0
@@ -2037,7 +2047,9 @@ fun ChatScreen(
2037 }
2047 }
2038 // When a search yields no matches (but the conversation isn't empty), say so
2048 // When a search yields no matches (but the conversation isn't empty), say so
2039 // instead of showing a blank list.
2049 // instead of showing a blank list.
2050+ val noMatches = searchActive && searchQuery.isNotEmpty() &&
2040− if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) {
2051+ !searchFiltering && searchMessages.isEmpty() && messages.isNotEmpty()
2052+ if (noMatches) {
2041 Text(
2053 Text(
2042 stringResource(R.string.search_no_matches),
2054 stringResource(R.string.search_no_matches),
2043 style = MaterialTheme.typography.bodyMedium,
2055 style = MaterialTheme.typography.bodyMedium,
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+16−1
@@ -507,6 +507,13 @@ class ChatViewModel(
507 private val _pendingPermission = MutableStateFlow<Permission?>(null)
507 private val _pendingPermission = MutableStateFlow<Permission?>(null)
508 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
508 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
509
509
510+ // Permission ids already responded to, guarding against a duplicate response after a
511+ // config change: the dialog's `responded` flag uses plain `remember` so a re-queued
512+ // permission is always answerable, but a rotation between tap and VM resolution resets
513+ // it to false, allowing a second tap. Cleared on failure (so a re-queued permission is
514+ // answerable) and on connection change.
515+ private val respondedPermissionIds = mutableSetOf<String>()
516+
510 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
517 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
511 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
518 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
512 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
519 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
@@ -553,6 +560,7 @@ class ChatViewModel(
553
560
554 private fun clearPermissions() {
561 private fun clearPermissions() {
555 pendingPermissions.clear()
562 pendingPermissions.clear()
563+ respondedPermissionIds.clear()
556 _pendingPermission.value = null
564 _pendingPermission.value = null
557 updatePermissionProgress()
565 updatePermissionProgress()
558 }
566 }
@@ -873,6 +881,7 @@ class ChatViewModel(
873 // user intent that is now persisted; wiping it on every (re)connect — which
881 // user intent that is now persisted; wiping it on every (re)connect — which
874 // this collector does, including transient SSE drops and the cold-start
882 // this collector does, including transient SSE drops and the cold-start
875 // restore path — would drop a legitimately queued/recovered follow-up.
883 // restore path — would drop a legitimately queued/recovered follow-up.
884+ hasShownMessages = false
876 _selectedAgent.value = null
885 _selectedAgent.value = null
877 // Apply the user's persisted preferred-agent for this new session/load, so a
886 // Apply the user's persisted preferred-agent for this new session/load, so a
878 // user who always runs a specific agent doesn't have to pick it every session.
887 // user who always runs a specific agent doesn't have to pick it every session.
@@ -1447,6 +1456,7 @@ class ChatViewModel(
1447 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1456 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1448 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1457 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1449 runEndedByIdle = false
1458 runEndedByIdle = false
1459+ lastSentPrompt = null
1450 viewModelScope.launch {
1460 viewModelScope.launch {
1451 runCatchingCancellable {
1461 runCatchingCancellable {
1452 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1462 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
@@ -1500,7 +1510,7 @@ class ChatViewModel(
1500 fun resendLastPrompt() {
1510 fun resendLastPrompt() {
1501 val prompt = lastSentPrompt ?: return
1511 val prompt = lastSentPrompt ?: return
1502 lastSentPrompt = null
1512 lastSentPrompt = null
1503− send(prompt, includeAttachments = false)
1513+ if (!send(prompt, includeAttachments = false)) lastSentPrompt = prompt
1504 }
1514 }
1505
1515
1506 fun addAttachment(attachment: PendingAttachment) {
1516 fun addAttachment(attachment: PendingAttachment) {
@@ -1842,6 +1852,7 @@ class ChatViewModel(
1842 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1852 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1843 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1853 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1844 runEndedByIdle = false
1854 runEndedByIdle = false
1855+ lastSentPrompt = null
1845 viewModelScope.launch {
1856 viewModelScope.launch {
1846 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1857 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1847 .onFailure {
1858 .onFailure {
@@ -1861,6 +1872,7 @@ class ChatViewModel(
1861 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1872 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1862 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1873 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1863 runEndedByIdle = false
1874 runEndedByIdle = false
1875+ lastSentPrompt = null
1864 viewModelScope.launch {
1876 viewModelScope.launch {
1865 runCatchingCancellable { conn.api.initSession(sessionId) }
1877 runCatchingCancellable { conn.api.initSession(sessionId) }
1866 .onFailure {
1878 .onFailure {
@@ -1887,6 +1899,7 @@ class ChatViewModel(
1887 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1899 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1888 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1900 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1889 runEndedByIdle = false
1901 runEndedByIdle = false
1902+ lastSentPrompt = null
1890 viewModelScope.launch {
1903 viewModelScope.launch {
1891 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1904 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1892 .onFailure {
1905 .onFailure {
@@ -1977,6 +1990,7 @@ class ChatViewModel(
1977
1990
1978 fun respondPermission(permission: Permission, response: PermissionResponse) {
1991 fun respondPermission(permission: Permission, response: PermissionResponse) {
1979 val conn = connection ?: return
1992 val conn = connection ?: return
1993+ if (!respondedPermissionIds.add(permission.id)) return
1980 // Record a session-scoped grant so subsequent matching requests are auto-answered.
1994 // Record a session-scoped grant so subsequent matching requests are auto-answered.
1981 // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1995 // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1982 // client-side set is what gives it the "for this session" semantics.
1996 // client-side set is what gives it the "for this session" semantics.
@@ -2007,6 +2021,7 @@ class ChatViewModel(
2007 val type = permission.type.orEmpty()
2021 val type = permission.type.orEmpty()
2008 val pattern = permission.patternText.orEmpty()
2022 val pattern = permission.patternText.orEmpty()
2009 if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
2023 if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
2024+ respondedPermissionIds.remove(permission.id)
2010 enqueuePermission(permission)
2025 enqueuePermission(permission)
2011 }
2026 }
2012 }
2027 }
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+6−1
@@ -336,7 +336,12 @@ private fun rememberDiffRows(
336 tertiary: Color,
336 tertiary: Color,
337 onSurface: Color,
337 onSurface: Color,
338 onSurfaceVariant: Color,
338 onSurfaceVariant: Color,
339−): DiffRows = produceState<DiffRows>(DiffRows(emptyList(), 0), diff, expanded, collapsedHunks) {
339+): DiffRows = produceState<DiffRows>(
340+ DiffRows(emptyList(), 0),
341+ diff, expanded, collapsedHunks,
342+ addColor, removeColor, addText, removeText,
343+ tertiary, onSurface, onSurfaceVariant,
344+) {
340 val result = withContext(Dispatchers.Default) {
345 val result = withContext(Dispatchers.Default) {
341 runCatchingCancellable {
346 runCatchingCancellable {
342 val lines = parseDiff(diff)
347 val lines = parseDiff(diff)
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+1−1
@@ -779,7 +779,7 @@ private fun DirectoryListing(
779 DropdownMenuItem(
779 DropdownMenuItem(
780 text = {
780 text = {
781 Row(verticalAlignment = Alignment.CenterVertically) {
781 Row(verticalAlignment = Alignment.CenterVertically) {
782− Text(stringResource(labels[key]!!), modifier = Modifier.weight(1f))
782+ Text(stringResource(labels.getValue(key)), modifier = Modifier.weight(1f))
783 if (sortKey == key) {
783 if (sortKey == key) {
784 Icon(
784 Icon(
785 if (sortDesc) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp,
785 if (sortDesc) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp,
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+8−8
@@ -176,14 +176,6 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
176 title = stringResource(R.string.not_connected),
176 title = stringResource(R.string.not_connected),
177 modifier = Modifier.align(Alignment.Center),
177 modifier = Modifier.align(Alignment.Center),
178 )
178 )
179− "Error" -> EmptyState(
180− icon = Icons.Filled.ErrorOutline,
181− title = stringResource(R.string.mcp_failed),
182− description = (state as? McpViewModel.State.Error)?.message,
183− modifier = Modifier.align(Alignment.Center),
184− actionLabel = stringResource(R.string.retry),
185− onAction = { vm.load() },
186− )
187 "Ready" ->
179 "Ready" ->
188 (state as? McpViewModel.State.Ready)?.let { s ->
180 (state as? McpViewModel.State.Ready)?.let { s ->
189 if (s.servers.isEmpty()) {
181 if (s.servers.isEmpty()) {
@@ -209,6 +201,14 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
209 }
201 }
210 }
202 }
211 }
203 }
204+ else -> if (key?.startsWith("Error") == true) EmptyState(
205+ icon = Icons.Filled.ErrorOutline,
206+ title = stringResource(R.string.mcp_failed),
207+ description = (state as? McpViewModel.State.Error)?.message,
208+ modifier = Modifier.align(Alignment.Center),
209+ actionLabel = stringResource(R.string.retry),
210+ onAction = { vm.load() },
211+ )
212 }
212 }
213 }
213 }
214 }
214 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+1−1
@@ -1107,8 +1107,8 @@ fun SettingsScreen(
1107 modifier = Modifier
1107 modifier = Modifier
1108 .fillMaxWidth()
1108 .fillMaxWidth()
1109 .defaultMinSize(minHeight = 48.dp)
1109 .defaultMinSize(minHeight = 48.dp)
1110− .padding(top = 8.dp)
1111 .clickable(role = Role.Button) { onManageServers() }
1110 .clickable(role = Role.Button) { onManageServers() }
1111+ .padding(top = 8.dp)
1112 .padding(vertical = 8.dp),
1112 .padding(vertical = 8.dp),
1113 verticalAlignment = Alignment.CenterVertically,
1113 verticalAlignment = Alignment.CenterVertically,
1114 ) {
1114 ) {
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+8−8
@@ -142,14 +142,6 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
142 title = stringResource(R.string.not_connected),
142 title = stringResource(R.string.not_connected),
143 modifier = Modifier.align(Alignment.Center),
143 modifier = Modifier.align(Alignment.Center),
144 )
144 )
145− "Error" -> EmptyState(
146− icon = Icons.Filled.ErrorOutline,
147− title = stringResource(R.string.usage_failed),
148− description = (state as? UsageViewModel.State.Error)?.message,
149− modifier = Modifier.align(Alignment.Center),
150− actionLabel = stringResource(R.string.retry),
151− onAction = { vm.load() },
152− )
153 "Ready" ->
145 "Ready" ->
154 (state as? UsageViewModel.State.Ready)?.let { s ->
146 (state as? UsageViewModel.State.Ready)?.let { s ->
155 if (s.report.isEmpty) {
147 if (s.report.isEmpty) {
@@ -169,6 +161,14 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
169 )
161 )
170 }
162 }
171 }
163 }
164+ else -> if (key?.startsWith("Error") == true) EmptyState(
165+ icon = Icons.Filled.ErrorOutline,
166+ title = stringResource(R.string.usage_failed),
167+ description = (state as? UsageViewModel.State.Error)?.message,
168+ modifier = Modifier.align(Alignment.Center),
169+ actionLabel = stringResource(R.string.retry),
170+ onAction = { vm.load() },
171+ )
172 }
172 }
173 }
173 }
174 }
174 }