git.iko.soy/opencode-android
on master help sign in
Fix 15 bugs across UI, data, notification, and service layers
commitae3e393e3994938daca0fc3049fae99f0a59d7a1 authoriko <mail@iko.soy> dateTue 7 Jul 2026, 20:39 (3 months ago) parent8a50d4d
12 files changed+158−100
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+56−69 app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+19−2 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+7−4 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+11−2 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+10 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+15−3 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+16−1 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+6−1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+1−1 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+8−8 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+1−1 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+8−8
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+56−69
@@ -6,6 +6,7 @@ import kotlinx.serialization.decodeFromString
66import kotlinx.serialization.encodeToString
77import kotlinx.serialization.json.Json
88import soy.iko.opencode.data.model.ServerProfile
9+import soy.iko.opencode.util.runCatchingCancellable
910
1011/** A server entry in a backup. [password] is present only when the user opted to include it. */
1112@Serializable
@@ -137,78 +138,64 @@ class BackupManager(
137138 require(data.version in supportedVersions) {
138139 "Unsupported backup version ${data.version}; supported: $supportedVersions"
139140 }
141+ var hadError = false
140142 for (s in data.servers) {
141− val username = s.username?.takeIf { it.isNotBlank() }
142− 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 {
149− profileStore.resolve(
150− ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
151− ).password
152− }
153− profileStore.save(
154− ServerProfile(
155− id = s.id,
156− label = s.label,
157− baseUrl = s.baseUrl,
158− username = username,
159− password = password,
160− lastUsed = 0,
161− requireHttps = s.requireHttps,
162− certPin = s.certPin?.takeIf { it.isNotBlank() },
163− ),
164− )
143+ runCatchingCancellable {
144+ val username = s.username?.takeIf { it.isNotBlank() }
145+ val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
146+ val password = incomingPassword ?: username?.let {
147+ profileStore.resolve(
148+ ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
149+ ).password
150+ }
151+ profileStore.save(
152+ ServerProfile(
153+ id = s.id,
154+ label = s.label,
155+ baseUrl = s.baseUrl,
156+ username = username,
157+ password = password,
158+ lastUsed = 0,
159+ requireHttps = s.requireHttps,
160+ certPin = s.certPin?.takeIf { it.isNotBlank() },
161+ ),
162+ )
163+ }.onFailure { hadError = true }
165164 }
166165 data.settings?.let { settings ->
167− // Parse the enum defensively (a bad name from an edited backup shouldn't abort the
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) }
171− settingsStore.setDynamicColor(settings.dynamicColor)
172− settingsStore.setSendOnEnter(settings.sendOnEnter)
173− settingsStore.setAppLock(settings.appLock)
174− settingsStore.setAppLockReLockSeconds(settings.appLockReLockSeconds)
175− settingsStore.setChatTextScale(settings.chatTextScale)
176− 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)
183− settingsStore.setSessionShowArchived(settings.sessionShowArchived)
184− settingsStore.setPreferredModelId(settings.preferredModelId)
185− settingsStore.setPreferredAgentName(settings.preferredAgentName)
186− settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
187− settingsStore.setHapticsEnabled(settings.hapticsEnabled)
188− 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
195− .find { it.name == settings.sessionSortMode }
196− ?.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
202− if (lang.isEmpty() || lang.matches(Regex("^[a-z]{2,3}(-[A-Za-z0-9]+)*$"))) {
203− settingsStore.setLanguageOverride(lang)
204− }
205− settingsStore.setNotifRunComplete(settings.notifRunComplete)
206− settingsStore.setNotifPermission(settings.notifPermission)
207− settingsStore.setNotifError(settings.notifError)
208− SwipeAction.entries.find { it.name == settings.swipeLeftAction }?.let { settingsStore.setSwipeLeftAction(it) }
209− SwipeAction.entries.find { it.name == settings.swipeRightAction }?.let { settingsStore.setSwipeRightAction(it) }
166+ runCatchingCancellable {
167+ ThemeMode.entries.find { it.name == settings.themeMode }?.let { settingsStore.setThemeMode(it) }
168+ settingsStore.setDynamicColor(settings.dynamicColor)
169+ settingsStore.setSendOnEnter(settings.sendOnEnter)
170+ settingsStore.setAppLock(settings.appLock)
171+ settingsStore.setAppLockReLockSeconds(settings.appLockReLockSeconds)
172+ settingsStore.setChatTextScale(settings.chatTextScale)
173+ settingsStore.setCodeWrap(settings.codeWrap)
174+ settingsStore.setSessionSortDescending(settings.sessionSortDescending)
175+ settingsStore.setSessionShowArchived(settings.sessionShowArchived)
176+ settingsStore.setPreferredModelId(settings.preferredModelId)
177+ settingsStore.setPreferredAgentName(settings.preferredAgentName)
178+ settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
179+ settingsStore.setHapticsEnabled(settings.hapticsEnabled)
180+ settingsStore.setReducedMotion(settings.reducedMotion)
181+ soy.iko.opencode.ui.session.SessionSortMode.entries
182+ .find { it.name == settings.sessionSortMode }
183+ ?.let { settingsStore.setSessionSortMode(it.name) }
184+ val lang = settings.languageOverride
185+ if (lang.isEmpty() || lang.matches(Regex("^[a-z]{2,3}(-[A-Za-z0-9]+)*$"))) {
186+ settingsStore.setLanguageOverride(lang)
187+ }
188+ settingsStore.setNotifRunComplete(settings.notifRunComplete)
189+ settingsStore.setNotifPermission(settings.notifPermission)
190+ settingsStore.setNotifError(settings.notifError)
191+ SwipeAction.entries.find { it.name == settings.swipeLeftAction }?.let { settingsStore.setSwipeLeftAction(it) }
192+ SwipeAction.entries.find { it.name == settings.swipeRightAction }?.let { settingsStore.setSwipeRightAction(it) }
193+ }.onFailure { hadError = true }
210194 }
211− data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
212− data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
195+ runCatchingCancellable {
196+ data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
197+ data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
198+ }.onFailure { hadError = true }
199+ if (hadError) throw IllegalStateException("Backup import completed with errors")
213200 }
214201}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+19−2
@@ -92,7 +92,16 @@ open class OutboxStore private constructor(
9292 // offline-composed messages.
9393 val diskList = withContext(Dispatchers.IO) {
9494 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+ }
96105 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
97106 // the in-memory version on conflict (it's newer).
98107 if (_messages.value.isEmpty()) {
@@ -163,7 +172,15 @@ open class OutboxStore private constructor(
163172 try {
164173 tmp.writeText(encoded)
165174 if (!tmp.renameTo(f)) {
166− f.writeText(encoded)
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)
182+ f.writeText(encoded)
183+ }
167184 }
168185 } finally {
169186 tmp.delete()
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+7−4
@@ -892,7 +892,10 @@ open class AppContainer private constructor(
892892 // message.updated, treating it as live would re-add the session to activeRuns with no
893893 // following SessionIdle to clear it — pinning anyRunActive true indefinitely when such
894894 // a part is replayed/reordered after the run already went idle.
895− val part = (event as MessagePartUpdated).properties.part
895+ val part = when (event) {
896+ is MessagePartUpdated -> event.properties.part
897+ else -> return false
898+ }
896899 return part !is StepFinishPart
897900 }
898901
@@ -1260,7 +1263,7 @@ open class AppContainer private constructor(
12601263 _runProgressText.value = null
12611264 }
12621265 if (previousProfileId != profile.id) {
1263− _unread.value = emptyMap()
1266+ _unread.update { emptyMap() }
12641267 unreadMessageIds.clear()
12651268 }
12661269 val now = System.currentTimeMillis()
@@ -1307,7 +1310,7 @@ open class AppContainer private constructor(
13071310 // "Step 2 of 5" exposed in the public StateFlow with no run actually active.
13081311 _runProgressText.value = null
13091312 }
1310− _unread.value = emptyMap()
1313+ _unread.update { emptyMap() }
13111314 unreadMessageIds.clear()
13121315 }
13131316
@@ -1538,7 +1541,7 @@ open class AppContainer private constructor(
15381541 // immediately on cold start (before any session list composition).
15391542 appScope.launch {
15401543 runCatchingCancellable {
1541− sessionPrefsStore.muted.collect { ids -> _mutedSessions.value = ids }
1544+ sessionPrefsStore.muted.collect { ids -> _mutedSessions.update { ids } }
15421545 }
15431546 }
15441547 // 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
1414import androidx.core.app.NotificationCompat
1515import androidx.core.app.NotificationManagerCompat
1616import androidx.core.app.ServiceCompat
17+import androidx.core.content.ContextCompat
1718import soy.iko.opencode.MainActivity
1819import soy.iko.opencode.R
1920import java.security.MessageDigest
@@ -63,6 +64,7 @@ class RunForegroundService : Service() {
6364 @android.annotation.SuppressLint("MissingPermission")
6465 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
6566 instance = this
67+ runCatching { NotificationManagerCompat.from(this).cancel(NOTIF_TIMEOUT_ID) }
6668 // The session title (when a single run is active) is passed via the intent extra so
6769 // the notification can identify which session is running, instead of a generic
6870 // "Agent is working…". Null/blank falls back to the generic title.
@@ -243,6 +245,10 @@ class RunForegroundService : Service() {
243245 * timed out by the system — the run continues but may be killed by Doze without warning. */
244246 @SuppressLint("MissingPermission")
245247 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
246252 runCatching {
247253 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
248254 .setSmallIcon(R.drawable.ic_stat_notify)
@@ -260,13 +266,16 @@ class RunForegroundService : Service() {
260266 override fun onDestroy() {
261267 // Safety net: if the process is being killed while a run is in progress,
262268 // 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
263273 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
265275 // begins fresh and startForeground is re-asserted on the first intent of the next
266276 // run. These are instance fields, so a recreated service resets them by construction
267277 // — but clearing here also covers the same-instance restart case.
268278 runStartMillis = 0L
269− isInForeground = false
270279 lastTitle = null
271280 lastSessionId = null
272281 lastProfileId = null
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+10
@@ -200,6 +200,7 @@ object SessionNotifications {
200200 .setContentText(context.getString(R.string.app_name))
201201 .setContentIntent(contentIntent)
202202 .setAutoCancel(true)
203+ .setOnlyAlertOnce(true)
203204 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
204205 .setGroup(GROUP_COMPLETED)
205206 .setGroupSummary(true)
@@ -560,10 +561,19 @@ object SessionNotifications {
560561 @SuppressLint("MissingPermission")
561562 private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
562563 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+ )
563571 val summary = NotificationCompat.Builder(context, channelId)
564572 .setSmallIcon(R.drawable.ic_stat_notify)
565573 .setContentTitle(context.getString(R.string.app_name))
574+ .setContentIntent(contentIntent)
566575 .setAutoCancel(true)
576+ .setOnlyAlertOnce(true)
567577 .setGroup(group)
568578 .setGroupSummary(true)
569579 .build()
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+15−3
@@ -315,6 +315,7 @@ fun ChatScreen(
315315 // the list to each one, mirroring desktop chat apps (the bare filtered list previously
316316 // required reading each message to find the term).
317317 var searchPos by rememberSaveable { mutableIntStateOf(0) }
318+ var searchFiltering by remember { mutableStateOf(false) }
318319 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
319320 // a confirmation dialog (from both the overflow menu and the command palette).
320321 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
@@ -1035,6 +1036,7 @@ fun ChatScreen(
10351036 }
10361037 }
10371038 }
1039+ searchFiltering = false
10381040 }
10391041 filtered
10401042 }
@@ -1045,10 +1047,11 @@ fun ChatScreen(
10451047 // the last valid index (keyed on size so a content refresh that shrinks the matches
10461048 // without a query change doesn't leave searchPos out of range).
10471049 LaunchedEffect(searchQuery) {
1050+ if (searchQuery.isNotEmpty()) searchFiltering = true
10481051 searchPos = 0
10491052 }
10501053 LaunchedEffect(searchMessages.size) {
1051− if (searchPos >= searchMessages.size) searchPos = 0
1054+ if (searchPos >= searchMessages.size) searchPos = (searchMessages.size - 1).coerceAtLeast(0)
10521055 }
10531056 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
10541057 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
@@ -1177,7 +1180,7 @@ fun ChatScreen(
11771180 // @Composable property, so we read it inside a snapshotFlow registered in the composable
11781181 // scope; the flow re-emits whenever the IME inset changes (open/close transitions).
11791182 val density = androidx.compose.ui.platform.LocalDensity.current
1180− var wasImeOpen by remember { mutableStateOf(false) }
1183+ var wasImeOpen by rememberSaveable { mutableStateOf(false) }
11811184 val imeBottomPx = WindowInsets.ime.getBottom(density)
11821185 androidx.compose.runtime.LaunchedEffect(imeBottomPx, listItems.isNotEmpty(), isPinnedToBottom) {
11831186 if (listItems.isEmpty()) return@LaunchedEffect
@@ -1196,6 +1199,7 @@ fun ChatScreen(
11961199 LaunchedEffect(Unit) {
11971200 var prevSize = 0
11981201 var prevLen = 0
1202+ var initialized = false
11991203 snapshotFlow {
12001204 // Track list size + the streaming length of the last part so we auto-scroll
12011205 // as content arrives, plus the pinned flag. A small data class with primitive
@@ -1207,6 +1211,12 @@ fun ChatScreen(
12071211 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
12081212 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
12091213 }.collect { signal ->
1214+ if (!initialized) {
1215+ prevSize = signal.size
1216+ prevLen = signal.lastTextLength
1217+ initialized = true
1218+ return@collect
1219+ }
12101220 if (signal.pinned) {
12111221 // Back at the bottom: clear the new-content badge.
12121222 newContentCount = 0
@@ -2037,7 +2047,9 @@ fun ChatScreen(
20372047 }
20382048 // When a search yields no matches (but the conversation isn't empty), say so
20392049 // instead of showing a blank list.
2040− if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) {
2050+ val noMatches = searchActive && searchQuery.isNotEmpty() &&
2051+ !searchFiltering && searchMessages.isEmpty() && messages.isNotEmpty()
2052+ if (noMatches) {
20412053 Text(
20422054 stringResource(R.string.search_no_matches),
20432055 style = MaterialTheme.typography.bodyMedium,
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+16−1
@@ -507,6 +507,13 @@ class ChatViewModel(
507507 private val _pendingPermission = MutableStateFlow<Permission?>(null)
508508 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
509509
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+
510517 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
511518 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
512519 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
@@ -553,6 +560,7 @@ class ChatViewModel(
553560
554561 private fun clearPermissions() {
555562 pendingPermissions.clear()
563+ respondedPermissionIds.clear()
556564 _pendingPermission.value = null
557565 updatePermissionProgress()
558566 }
@@ -873,6 +881,7 @@ class ChatViewModel(
873881 // user intent that is now persisted; wiping it on every (re)connect — which
874882 // this collector does, including transient SSE drops and the cold-start
875883 // restore path — would drop a legitimately queued/recovered follow-up.
884+ hasShownMessages = false
876885 _selectedAgent.value = null
877886 // Apply the user's persisted preferred-agent for this new session/load, so a
878887 // user who always runs a specific agent doesn't have to pick it every session.
@@ -1447,6 +1456,7 @@ class ChatViewModel(
14471456 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
14481457 // reconnect keeps the working indicator lit instead of leaving it stuck off.
14491458 runEndedByIdle = false
1459+ lastSentPrompt = null
14501460 viewModelScope.launch {
14511461 runCatchingCancellable {
14521462 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
@@ -1500,7 +1510,7 @@ class ChatViewModel(
15001510 fun resendLastPrompt() {
15011511 val prompt = lastSentPrompt ?: return
15021512 lastSentPrompt = null
1503− send(prompt, includeAttachments = false)
1513+ if (!send(prompt, includeAttachments = false)) lastSentPrompt = prompt
15041514 }
15051515
15061516 fun addAttachment(attachment: PendingAttachment) {
@@ -1842,6 +1852,7 @@ class ChatViewModel(
18421852 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
18431853 // reconnect keeps the working indicator lit instead of leaving it stuck off.
18441854 runEndedByIdle = false
1855+ lastSentPrompt = null
18451856 viewModelScope.launch {
18461857 runCatchingCancellable { conn.api.summarize(sessionId, model) }
18471858 .onFailure {
@@ -1861,6 +1872,7 @@ class ChatViewModel(
18611872 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
18621873 // reconnect keeps the working indicator lit instead of leaving it stuck off.
18631874 runEndedByIdle = false
1875+ lastSentPrompt = null
18641876 viewModelScope.launch {
18651877 runCatchingCancellable { conn.api.initSession(sessionId) }
18661878 .onFailure {
@@ -1887,6 +1899,7 @@ class ChatViewModel(
18871899 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
18881900 // reconnect keeps the working indicator lit instead of leaving it stuck off.
18891901 runEndedByIdle = false
1902+ lastSentPrompt = null
18901903 viewModelScope.launch {
18911904 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
18921905 .onFailure {
@@ -1977,6 +1990,7 @@ class ChatViewModel(
19771990
19781991 fun respondPermission(permission: Permission, response: PermissionResponse) {
19791992 val conn = connection ?: return
1993+ if (!respondedPermissionIds.add(permission.id)) return
19801994 // Record a session-scoped grant so subsequent matching requests are auto-answered.
19811995 // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
19821996 // client-side set is what gives it the "for this session" semantics.
@@ -2007,6 +2021,7 @@ class ChatViewModel(
20072021 val type = permission.type.orEmpty()
20082022 val pattern = permission.patternText.orEmpty()
20092023 if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
2024+ respondedPermissionIds.remove(permission.id)
20102025 enqueuePermission(permission)
20112026 }
20122027 }
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+6−1
@@ -336,7 +336,12 @@ private fun rememberDiffRows(
336336 tertiary: Color,
337337 onSurface: Color,
338338 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+) {
340345 val result = withContext(Dispatchers.Default) {
341346 runCatchingCancellable {
342347 val lines = parseDiff(diff)
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+1−1
@@ -779,7 +779,7 @@ private fun DirectoryListing(
779779 DropdownMenuItem(
780780 text = {
781781 Row(verticalAlignment = Alignment.CenterVertically) {
782− Text(stringResource(labels[key]!!), modifier = Modifier.weight(1f))
782+ Text(stringResource(labels.getValue(key)), modifier = Modifier.weight(1f))
783783 if (sortKey == key) {
784784 Icon(
785785 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) {
176176 title = stringResource(R.string.not_connected),
177177 modifier = Modifier.align(Alignment.Center),
178178 )
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− )
187179 "Ready" ->
188180 (state as? McpViewModel.State.Ready)?.let { s ->
189181 if (s.servers.isEmpty()) {
@@ -209,6 +201,14 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
209201 }
210202 }
211203 }
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+ )
212212 }
213213 }
214214 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+1−1
@@ -1107,8 +1107,8 @@ fun SettingsScreen(
11071107 modifier = Modifier
11081108 .fillMaxWidth()
11091109 .defaultMinSize(minHeight = 48.dp)
1110− .padding(top = 8.dp)
11111110 .clickable(role = Role.Button) { onManageServers() }
1111+ .padding(top = 8.dp)
11121112 .padding(vertical = 8.dp),
11131113 verticalAlignment = Alignment.CenterVertically,
11141114 ) {
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
142142 title = stringResource(R.string.not_connected),
143143 modifier = Modifier.align(Alignment.Center),
144144 )
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− )
153145 "Ready" ->
154146 (state as? UsageViewModel.State.Ready)?.let { s ->
155147 if (s.report.isEmpty) {
@@ -169,6 +161,14 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
169161 )
170162 }
171163 }
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+ )
172172 }
173173 }
174174 }