git.iko.soy/opencode-android
on master help sign in
Implement UI/UX improvements: optimistic send with per-message status, in-chat delete undo, scroll-to-top, FGS task-plan progress, auth/endpoint failure split, unread persistence across reconnects, connect timeout, notification Stop/Mark-read/reply-sent actions, crash-relaunch prompt, notification rationale pre-prompt, splash icon, ServerCard dedupe
commit73868e9a2690299810715a966124bbd899b2745f authoriko <mail@iko.soy> dateFri 3 Jul 2026, 12:45 (3 months ago) parentdecfac5
24 files changed+885−256
app/detekt-baseline.xml+4 app/src/main/java/soy/iko/opencode/MainActivity.kt+36−2 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17−15 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+6 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+18 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+117−9 app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+36−6 app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt+3−1 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+54−8 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+68 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+48−7 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+67−25 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+119−20 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+99−27 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+14−2 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+10−3 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+100−124 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+12−3 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+2−1 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+6−3 app/src/main/res/values-es/strings.xml+21 app/src/main/res/values-night/colors.xml+6 app/src/main/res/values/strings.xml+21 app/src/main/res/values/themes.xml+1
app/detekt-baseline.xml+4
@@ -5,13 +5,17 @@
55 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
66 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
77 <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, onOpenSession: ((String) -&gt; Unit)? = null, )</ID>
8+ <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
89 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
10+ <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
911 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
1012 <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, )</ID>
13+ <ID>LargeClass:AppContainer.kt$AppContainer</ID>
1114 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
1215 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
1316 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
1417 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
18+ <ID>TooManyFunctions:CrashLogger.kt$CrashLogger</ID>
1519 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
1620 </CurrentIssues>
1721</SmellBaseline>
app/src/main/java/soy/iko/opencode/MainActivity.kt+36−2
@@ -104,6 +104,25 @@ class MainActivity : FragmentActivity() {
104104 }
105105 }
106106 }
107+ // Surface a "crashed last time" prompt if a crash report from a previous run
108+ // hasn't been acknowledged. Acknowledges immediately so it doesn't re-fire on
109+ // every rotation/resume; the user can always find reports in Settings → Diagnostics.
110+ maybeShowCrashPrompt()
111+ }
112+
113+ private fun maybeShowCrashPrompt() {
114+ val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
115+ if (!crashLogger.hasUnacknowledgedCrash()) return
116+ crashLogger.acknowledgeCrashes()
117+ android.app.AlertDialog.Builder(this)
118+ .setTitle(R.string.crash_last_run_title)
119+ .setMessage(R.string.crash_last_run_text)
120+ .setPositiveButton(R.string.crash_last_run_view) { _, _ ->
121+ (application as OpencodeApp).container.requestDiagnostics()
122+ }
123+ .setNegativeButton(R.string.crash_last_run_dismiss, null)
124+ .setCancelable(true)
125+ .show()
107126 }
108127
109128 override fun onNewIntent(intent: Intent) {
@@ -235,7 +254,9 @@ class MainActivity : FragmentActivity() {
235254 }
236255
237256 /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
238− * Idempotent per Activity instance via [notificationPermissionRequested]. */
257+ * Idempotent per Activity instance via [notificationPermissionRequested].
258+ * Shows a rationale dialog first (explaining what the user gains) before the system
259+ * permission prompt, so a user who denies understands what they're missing. */
239260 private fun maybeRequestNotificationPermission() {
240261 if (notificationPermissionRequested) return
241262 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
@@ -243,7 +264,20 @@ class MainActivity : FragmentActivity() {
243264 PackageManager.PERMISSION_GRANTED
244265 if (!granted) {
245266 notificationPermissionRequested = true
246− requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
267+ showNotificationRationaleDialog()
247268 }
248269 }
270+
271+ private fun showNotificationRationaleDialog() {
272+ val ctx = this
273+ android.app.AlertDialog.Builder(ctx)
274+ .setTitle(R.string.notif_rationale_title)
275+ .setMessage(R.string.notif_rationale_text)
276+ .setPositiveButton(R.string.notif_rationale_allow) { _, _ ->
277+ runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
278+ }
279+ .setNegativeButton(R.string.notif_rationale_skip, null)
280+ .setCancelable(false)
281+ .show()
282+ }
249283}
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17−15
@@ -50,7 +50,7 @@ open class EventStreamClient(
5050 HttpClient(io.ktor.client.engine.okhttp.OkHttp) {},
5151 CoroutineScope(EmptyCoroutineContext),
5252 )
53− enum class ConnectionState { Disconnected, Connecting, Connected, Failed }
53+ enum class ConnectionState { Disconnected, Connecting, Connected, Failed, AuthFailed }
5454
5555 private val _state = MutableStateFlow(ConnectionState.Disconnected)
5656 open val state: StateFlow<ConnectionState> = _state.asStateFlow()
@@ -68,16 +68,12 @@ open class EventStreamClient(
6868 /** Conflated channel used to cut the reconnect backoff short when network returns. */
6969 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
7070
71− /** Returns true if the exception is a non-retryable SSE failure that should stop the retry
72− * loop and park until an explicit reconnect. This covers any 4xx except 408 (Request Timeout)
73− * and 429 (Too Many Requests), which are transient — mirroring the REST layer's withRetry
74− * classification (401/403 bad credentials, 404 renamed/removed endpoint, 400 bad request, ...
75− * won't succeed on retry).
76− * ktor's SSE builder wraps establishment failures in [io.ktor.client.plugins.sse.SSEClientException]
77− * (an IllegalStateException, NOT a ClientRequestException), attaching the response and keeping any
78− * underlying ClientRequestException only as the cause — so we inspect the wrapper's response status
79− * and walk the whole cause chain rather than matching a single exception type. */
80− private fun isNonRetryableSseFailure(e: Throwable): Boolean =
71+ /** Classifies a non-retryable SSE failure so the UI can show the right recovery hint:
72+ * an auth failure (401/403) reads "check credentials", while other 4xx (400/404/405…)
73+ * reads "check the server URL and version". Returns null for transient/retryable errors. */
74+ private enum class SseFailureKind { Auth, Other }
75+
76+ private fun classifyNonRetryableSseFailure(e: Throwable): SseFailureKind? =
8177 generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
8278 .take(16)
8379 .mapNotNull { t ->
@@ -88,7 +84,12 @@ open class EventStreamClient(
8884 }
8985 }
9086 // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
91− .any { it in 400..499 && it != 408 && it != 429 }
87+ .map { code -> when {
88+ code in 400..499 && code != 408 && code != 429 ->
89+ if (code == 401 || code == 403) SseFailureKind.Auth else SseFailureKind.Other
90+ else -> null
91+ } }
92+ .firstOrNull { it != null }
9293
9394 /** Request an immediate reconnect, skipping any in-progress backoff. */
9495 open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
@@ -254,13 +255,14 @@ open class EventStreamClient(
254255 } catch (e: Exception) {
255256 // If the scope was cancelled (e.g. connection close), the closed-client
256257 // exception is expected — don't log a spurious "stream error" warning.
257− if (isNonRetryableSseFailure(e)) {
258+ val failureKind = classifyNonRetryableSseFailure(e)
259+ if (failureKind != null) {
258260 // The server rejected the request with a non-retryable 4xx (bad
259261 // credentials, missing endpoint, ...) — retrying won't help. Log a
260262 // scrubbed summary (class + status) instead of the full exception, whose
261263 // message carries the request URL and may include auth or paths.
262− Log.w("EventStream", "SSE non-retryable 4xx, awaiting explicit reconnect: ${safeExceptionSummary(e)}")
263− _state.value = ConnectionState.Failed
264+ Log.w("EventStream", "SSE non-retryable 4xx (${if (failureKind == SseFailureKind.Auth) "auth" else "endpoint"}), awaiting explicit reconnect: ${safeExceptionSummary(e)}")
265+ _state.value = if (failureKind == SseFailureKind.Auth) ConnectionState.AuthFailed else ConnectionState.Failed
264266 if (!isActive) {
265267 // Final teardown (last subscriber left) while a 4xx surfaced: end in
266268 // Disconnected like every other loop-exit path. Failed must stay
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+6
@@ -233,6 +233,12 @@ object NetworkConfig {
233233 * two-pane layout for top-level destination discoverability on large screens. */
234234 const val navigationRailThresholdDp = 840
235235
236+ /** Maximum width (dp) for the chat message list on large screens. On a tablet or
237+ * unfolded foldable, full-width message bubbles stretch edge-to-edge, hurting
238+ * readability — capping and centering the list (like Gmail/Telegram on tablets)
239+ * keeps line lengths comfortable. Applied only above [twoPaneWidthThresholdDp]. */
240+ const val chatContentMaxWidthDp = 800
241+
236242 // --- Motion tokens (shared across screens) ---
237243
238244 /** Duration (ms) for horizontal slide push/pop nav transitions. */
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+18
@@ -43,6 +43,7 @@ class CrashLogger private constructor(private val appContext: Context) {
4343
4444 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
4545 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
46+ private val crashPrefs = appContext.getSharedPreferences("crash_logger", android.content.Context.MODE_PRIVATE)
4647
4748 private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
4849 val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
@@ -98,6 +99,21 @@ class CrashLogger private constructor(private val appContext: Context) {
9899 }
99100 }
100101
102+ /** Whether any crash report is newer than the last-acknowledged timestamp — i.e. a
103+ * crash happened in a previous run that the user hasn't been prompted about yet.
104+ * Called on cold start to surface a "crashed last time" dialog. */
105+ fun hasUnacknowledgedCrash(): Boolean {
106+ val acknowledgedAt = crashPrefs.getLong(KEY_ACKNOWLEDGED_AT, 0L)
107+ return crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
108+ ?.any { it.lastModified() > acknowledgedAt } == true
109+ }
110+
111+ /** Mark all current crash reports as acknowledged so the cold-start prompt doesn't
112+ * re-fire on the next launch. */
113+ fun acknowledgeCrashes() {
114+ crashPrefs.edit().putLong(KEY_ACKNOWLEDGED_AT, System.currentTimeMillis()).apply()
115+ }
116+
101117 /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
102118 private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
103119
@@ -235,6 +251,8 @@ class CrashLogger private constructor(private val appContext: Context) {
235251 * crashes/ dir grows without limit until the user manually clears it. */
236252 private const val MAX_REPORTS = 20
237253
254+ private const val KEY_ACKNOWLEDGED_AT = "acknowledged_at"
255+
238256 /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
239257 internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
240258
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+117−9
@@ -19,6 +19,12 @@ import soy.iko.opencode.data.model.StepFinishPart
1919import soy.iko.opencode.data.model.ServerProfile
2020import soy.iko.opencode.data.model.SessionError
2121import soy.iko.opencode.data.model.SessionIdle
22+import soy.iko.opencode.data.model.ToolPart
23+import soy.iko.opencode.data.model.TODO_WRITE_TOOL
24+import soy.iko.opencode.data.model.parseTodos
25+import soy.iko.opencode.data.model.statusEnum
26+import soy.iko.opencode.data.model.TodoStatus
27+import soy.iko.opencode.data.model.inputElement
2228import soy.iko.opencode.data.repo.AttachmentDraftStore
2329import soy.iko.opencode.data.repo.BackupManager
2430import soy.iko.opencode.data.repo.DraftStore
@@ -48,13 +54,17 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
4854import kotlinx.coroutines.SupervisorJob
4955import kotlinx.coroutines.cancel
5056import kotlinx.coroutines.delay
57+import kotlinx.coroutines.flow.MutableSharedFlow
5158import kotlinx.coroutines.flow.MutableStateFlow
59+import kotlinx.coroutines.flow.SharedFlow
5260import kotlinx.coroutines.flow.StateFlow
61+import kotlinx.coroutines.flow.asSharedFlow
5362import kotlinx.coroutines.flow.asStateFlow
5463import kotlinx.coroutines.flow.collectLatest
5564import kotlinx.coroutines.flow.combine
5665import kotlinx.coroutines.flow.debounce
5766import kotlinx.coroutines.flow.distinctUntilChanged
67+import kotlinx.coroutines.flow.drop
5868import kotlinx.coroutines.flow.emptyFlow
5969import kotlinx.coroutines.flow.first
6070import kotlinx.coroutines.Job
@@ -170,6 +180,13 @@ open class AppContainer private constructor(
170180 }
171181 }
172182
183+ /** A one-shot signal to navigate to the Diagnostics screen (e.g. from the crash-relaunch
184+ * prompt). Consumed by OpencodeApp's NavHost. */
185+ private val _pendingDiagnostics = MutableStateFlow(false)
186+ open val pendingDiagnostics: StateFlow<Boolean> = _pendingDiagnostics.asStateFlow()
187+ open fun requestDiagnostics() { _pendingDiagnostics.value = true }
188+ open fun consumePendingDiagnostics(): Boolean = _pendingDiagnostics.compareAndSet(true, false)
189+
173190 /**
174191 * A session id to open from an external trigger (a notification tap or a deep link),
175192 * paired with the originating server's profile id (if known) so a tap after the user
@@ -208,6 +225,12 @@ open class AppContainer private constructor(
208225 private val _anyRunActive = MutableStateFlow(false)
209226 open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
210227
228+ /** Latest task-plan progress text for the active run (e.g. "Step 2 of 5"), surfaced in
229+ * the foreground-service notification so the user can see what the agent is working on
230+ * without opening the app. Null when no plan is available or no run is active. */
231+ private val _runProgressText = MutableStateFlow<String?>(null)
232+ open val runProgressText: StateFlow<String?> = _runProgressText.asStateFlow()
233+
211234 /** Whether the device currently has network connectivity. Distinct from the SSE
212235 * connection state so the UI can tell "you're offline" (device) from "server
213236 * unreachable" (credentials/host). Updated by the [networkCallback]. */
@@ -250,6 +273,9 @@ open class AppContainer private constructor(
250273 unreadMessageIds.remove(id)
251274 }
252275
276+ /** Mark a session as read (clear its unread badge) — the "Mark read" notification action. */
277+ open fun markSessionRead(id: String) = clearUnread(id)
278+
253279 /** Restore a session's unread badge after a failed server switch reconnects.
254280 * Preserves the prior [count] so a session badged with "5 unread" before the
255281 * switch attempt still shows 5 (not 1) after the restore. No-op for the session
@@ -317,23 +343,45 @@ open class AppContainer private constructor(
317343 .distinctUntilChanged()
318344 .collect { active ->
319345 if (active) {
320− // When exactly one run is active, resolve its title so the notification
321− // identifies which session is running. Multiple concurrent runs fall back
322− // to the generic title (naming one would be misleading).
323− val title = synchronized(activeRuns) {
346+ // When exactly one run is active, resolve its title (and id, for the
347+ // Stop action) so the notification identifies which session is running
348+ // and can be aborted from the notification. Multiple concurrent runs
349+ // fall back to the generic title (naming one would be misleading).
350+ val activeId = synchronized(activeRuns) {
324351 activeRuns.toList().singleOrNull()
325− }?.let { sid ->
352+ }
353+ val title = activeId?.let { sid ->
326354 runCatchingCancellable {
327355 activeConnection.value?.repository?.listSessions()
328356 ?.firstOrNull { it.id == sid }?.displayTitle
329357 }.getOrNull()
330358 }
331− RunForegroundService.start(ctx, title)
359+ RunForegroundService.start(ctx, title, activeId, _runProgressText.value)
332360 } else {
333361 RunForegroundService.stop(ctx)
334362 }
335363 }
336364 }
365+ // Separate collector: update the FGS notification's progress text ("Step 2 of 5")
366+ // as the agent works through its task plan. Re-sends the service intent with the
367+ // updated progress, which the service uses to rebuild the notification.
368+ appScope.launch {
369+ _runProgressText
370+ .drop(1) // skip initial value — the anyRunActive collector handles the first start
371+ .distinctUntilChanged()
372+ .collect { progress ->
373+ if (_anyRunActive.value) {
374+ val activeId = synchronized(activeRuns) { activeRuns.toList().singleOrNull() }
375+ val title = activeId?.let { sid ->
376+ runCatchingCancellable {
377+ activeConnection.value?.repository?.listSessions()
378+ ?.firstOrNull { it.id == sid }?.displayTitle
379+ }.getOrNull()
380+ }
381+ RunForegroundService.start(ctx, title, activeId, progress)
382+ }
383+ }
384+ }
337385 }
338386
339387 /**
@@ -544,6 +592,8 @@ open class AppContainer private constructor(
544592 if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
545593 }
546594 }
595+ // Clear the progress text when the run finishes.
596+ _runProgressText.value = null
547597 if (wasRunning && !isActivelyViewing(idleSid)) {
548598 // Fire this off the collector's coroutine: notifySessionCompleted
549599 // does a network listSessions() to resolve the title, and blocking
@@ -554,6 +604,24 @@ open class AppContainer private constructor(
554604 }
555605 return@collect
556606 }
607+ // Track the agent's task-plan progress (from todowrite tool calls) so the
608+ // foreground-service notification can show "Step 2 of 5" instead of just a
609+ // chronometer. The todowrite tool sends the full plan on every call, so the
610+ // latest call's input is a complete snapshot.
611+ if (event is MessagePartUpdated) {
612+ val part = event.properties.part
613+ if (part is ToolPart && part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) {
614+ val todos = parseTodos(part.state.inputElement())
615+ if (todos.isNotEmpty()) {
616+ val completed = todos.count { it.statusEnum() == TodoStatus.COMPLETED }
617+ _runProgressText.value = appContext?.getString(
618+ soy.iko.opencode.R.string.notif_running_progress,
619+ completed + 1,
620+ todos.size,
621+ )
622+ }
623+ }
624+ }
557625 val sid = sessionOf(event) ?: return@collect
558626 // Re-read currentSession here, right before mutating the badge state:
559627 // the user may have opened this session between when this event was
@@ -782,12 +850,31 @@ open class AppContainer private constructor(
782850 }.isSuccess
783851 }
784852 if (enqueued) flushOutbox()
785− } finally {
853+ } finally {
786854 onDone(enqueued)
787855 }
788856 }
789857 }
790858
859+ /** Abort the active run for [sessionId] from a notification Stop action. Routes to the
860+ * originating profile (or the active connection) so the abort reaches the server that
861+ * owns the session. Runs on the app scope so it survives the BroadcastReceiver's lifetime. */
862+ open fun abortRunFromNotification(sessionId: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
863+ appScope.launch {
864+ var success = false
865+ try {
866+ val conn = activeConnection.value?.takeIf {
867+ originProfileId == null || it.profile.id == originProfileId
868+ } ?: activeConnection.value
869+ if (conn != null) {
870+ success = runCatchingCancellable { conn.repository.abort(sessionId) }.isSuccess
871+ }
872+ } finally {
873+ onDone(success)
874+ }
875+ }
876+ }
877+
791878 /** Extract the session id an event pertains to, for message-activity events. */
792879 private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
793880
@@ -895,6 +982,10 @@ open class AppContainer private constructor(
895982 /** Build and install a connection to [profile], replacing any current one. Caller MUST hold
896983 * [connectionMutex]. */
897984 private suspend fun connectLocked(profile: ServerProfile): OpencodeConnection {
985+ // Capture the previous profile id before closing, so we can preserve unread badges
986+ // when reconnecting to the same server — a brief network blip or manual reconnect
987+ // shouldn't erase "5 unread". Only a genuine server switch clears them.
988+ val previousProfileId = _activeConnection.value?.profile?.id
898989 _activeConnection.value?.close()
899990 _activeConnection.value = null
900991 // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
@@ -905,8 +996,10 @@ open class AppContainer private constructor(
905996 activeRuns.clear()
906997 _anyRunActive.value = false
907998 }
908− _unread.value = emptyMap()
909− unreadMessageIds.clear()
999+ if (previousProfileId != profile.id) {
1000+ _unread.value = emptyMap()
1001+ unreadMessageIds.clear()
1002+ }
9101003 val now = System.currentTimeMillis()
9111004 val resolved = profileStore.resolve(profile)
9121005 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
@@ -999,6 +1092,21 @@ open class AppContainer private constructor(
9991092 * alive on the server, reappearing on the next refresh). */
10001093 private val pendingSessionDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
10011094
1095+ /** One-shot events carrying the id of a session scheduled for deferred deletion from
1096+ * outside the session list (e.g. from the chat screen's Delete). The SessionListScreen
1097+ * collects these to show an Undo snackbar, mirroring its own VM-level undo events. */
1098+ private val _externalSessionUndoEvents = MutableSharedFlow<String>(
1099+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
1100+ onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
1101+ )
1102+ val externalSessionUndoEvents: SharedFlow<String> = _externalSessionUndoEvents.asSharedFlow()
1103+
1104+ /** Emit an undo event for a session delete initiated outside the session list (e.g.
1105+ * from the chat screen). The SessionListScreen collects these to show an Undo snackbar. */
1106+ fun emitExternalSessionUndo(sessionId: String) {
1107+ _externalSessionUndoEvents.tryEmit(sessionId)
1108+ }
1109+
10021110 /**
10031111 * Schedule session [id] for deletion after [delayMs], cancellable via [cancelSessionDelete]
10041112 * (the Undo action). Also clears the session's stored draft on a successful delete.
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+36−6
@@ -31,8 +31,34 @@ class NotificationActionReceiver : BroadcastReceiver() {
3131
3232 override fun onReceive(context: Context, intent: Intent) {
3333 val container = (context.applicationContext as? OpencodeApp)?.container ?: return
34+ if (intent.action == ACTION_STOP_RUN) {
35+ // Stop action from the foreground-service notification. The session id may be
36+ // absent if multiple runs were active (generic notification); in that case we
37+ // can't target a specific run — just let it ride.
38+ val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
39+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
40+ val pending = goAsync()
41+ val finished = AtomicBoolean(false)
42+ var watchdogThread: Thread? = null
43+ val finishOnce = {
44+ if (finished.compareAndSet(false, true)) {
45+ watchdogThread?.interrupt()
46+ pending.finish()
47+ }
48+ }
49+ watchdogThread = watchdog(finishOnce)
50+ container.abortRunFromNotification(sessionId, profileId) { _ -> finishOnce() }
51+ return
52+ }
3453 val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
3554 when (intent.action) {
55+ ACTION_MARK_READ -> {
56+ // "Mark read" from a completion notification — clear the unread badge and
57+ // dismiss the notification without opening the session.
58+ container.markSessionRead(sessionId)
59+ SessionNotifications.cancel(context, sessionId)
60+ return
61+ }
3662 ACTION_PERMISSION -> {
3763 val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
3864 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
@@ -81,13 +107,15 @@ class NotificationActionReceiver : BroadcastReceiver() {
81107 }
82108 watchdogThread = watchdog(finishOnce)
83109 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
84− // The reply is durably queued (and flushed now if online); clear the
85− // "session ready" notification. If the enqueue failed, cancel it anyway and
86− // post a "reply not sent" error: leaving the completion notification up strands
87− // SystemUI's RemoteInput field in a permanent "sending…" spinner that can't be
88− // resubmitted, so "leave it up to retry" doesn't actually let the user retry.
110+ // The reply is durably queued (and flushed now if online). Show a brief
111+ // "Sent" confirmation by updating the completion notification's text, then
112+ // let it auto-cancel on tap — instead of vanishing silently. If the enqueue
113+ // failed, post a "reply not sent" error: leaving the completion notification
114+ // up strands SystemUI's RemoteInput field in a permanent "sending…" spinner
115+ // that can't be resubmitted, so "leave it up to retry" doesn't actually let
116+ // the user retry.
89117 if (enqueued) {
90− SessionNotifications.cancel(context, sessionId)
118+ SessionNotifications.postReplySent(context, sessionId)
91119 } else {
92120 SessionNotifications.postReplyFailed(context, sessionId, profileId)
93121 }
@@ -115,6 +143,8 @@ class NotificationActionReceiver : BroadcastReceiver() {
115143 companion object {
116144 const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
117145 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
146+ const val ACTION_STOP_RUN = "soy.iko.opencode.action.STOP_RUN"
147+ const val ACTION_MARK_READ = "soy.iko.opencode.action.MARK_READ"
118148 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
119149 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
120150 const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt+3−1
@@ -42,7 +42,9 @@ object NotificationChannels {
4242 },
4343 )
4444 nm.createNotificationChannel(
45− NotificationChannel(ERROR, context.getString(R.string.notif_channel_error), NotificationManager.IMPORTANCE_DEFAULT).apply {
45+ // HIGH importance so a failed run stands out from a completed one by
46+ // sound/vibration — both are actionable, but a failure is time-sensitive.
47+ NotificationChannel(ERROR, context.getString(R.string.notif_channel_error), NotificationManager.IMPORTANCE_HIGH).apply {
4648 description = context.getString(R.string.notif_channel_error_desc)
4749 },
4850 )
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+54−8
@@ -9,7 +9,9 @@ import android.content.pm.ServiceInfo
99import android.os.Build
1010import android.os.IBinder
1111import android.util.Log
12+import android.annotation.SuppressLint
1213import androidx.core.app.NotificationCompat
14+import androidx.core.app.NotificationManagerCompat
1315import androidx.core.app.ServiceCompat
1416import soy.iko.opencode.MainActivity
1517import soy.iko.opencode.R
@@ -30,7 +32,9 @@ class RunForegroundService : Service() {
3032 // the notification can identify which session is running, instead of a generic
3133 // "Agent is working…". Null/blank falls back to the generic title.
3234 val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
33− val notification = buildNotification(sessionTitle)
35+ val sessionId = intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
36+ val progress = intent?.getStringExtra(EXTRA_PROGRESS)?.takeIf { it.isNotBlank() }
37+ val notification = buildNotification(sessionTitle, sessionId, progress)
3438 // startForeground can throw ForegroundServiceStartNotAllowedException on
3539 // Android 12+ if the app is in the background when the service starts. Wrap
3640 // it so a backgrounded start (e.g. the user navigates away at the wrong
@@ -54,7 +58,7 @@ class RunForegroundService : Service() {
5458 return START_NOT_STICKY
5559 }
5660
57− private fun buildNotification(sessionTitle: String?): Notification {
61+ private fun buildNotification(sessionTitle: String?, sessionId: String?, progress: String? = null): Notification {
5862 // Tapping the notification opens the app so the user can see the running session.
5963 val openIntent = Intent(this, MainActivity::class.java).apply {
6064 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -63,8 +67,8 @@ class RunForegroundService : Service() {
6367 this, 0, openIntent,
6468 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
6569 )
66− val contentText = sessionTitle ?: getString(R.string.notif_running_text)
67− return NotificationCompat.Builder(this, NotificationChannels.STATUS)
70+ val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
71+ val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
6872 .setSmallIcon(R.drawable.ic_launcher_foreground)
6973 .setContentTitle(getString(R.string.notif_running_title))
7074 .setContentText(contentText)
@@ -77,24 +81,61 @@ class RunForegroundService : Service() {
7781 .setShowWhen(true)
7882 .setPriority(NotificationCompat.PRIORITY_LOW)
7983 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
80− .build()
84+ // Add a Stop action so the user can cancel the run without opening the app — the
85+ // core of the "kick it off and walk away" flow. Only when a specific session is
86+ // known (multiple concurrent runs can't be targeted individually).
87+ if (sessionId != null) {
88+ val stopIntent = Intent(this, NotificationActionReceiver::class.java).apply {
89+ action = NotificationActionReceiver.ACTION_STOP_RUN
90+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
91+ }
92+ val stopPending = PendingIntent.getBroadcast(
93+ this, sessionId.hashCode(), stopIntent,
94+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
95+ )
96+ builder.addAction(
97+ R.drawable.ic_launcher_foreground,
98+ getString(R.string.notif_action_stop),
99+ stopPending,
100+ )
101+ }
102+ return builder.build()
81103 }
82104
83105 // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
84106 // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
85107 // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
86− // continues without foreground priority. Both overloads are covered (the 2-arg form is
87− // API 35+).
108+ // continues without foreground priority. Post a low-priority notification so the user
109+ // knows their long-running task may be paused in the background, rather than silently
110+ // losing foreground priority. Both overloads are covered (the 2-arg form is API 35+).
88111 override fun onTimeout(startId: Int) {
89112 Log.w(TAG, "Foreground service timed out; stopping")
113+ postTimeoutNotification()
90114 stopSelf()
91115 }
92116
93117 override fun onTimeout(startId: Int, fgsType: Int) {
94118 Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
119+ postTimeoutNotification()
95120 stopSelf()
96121 }
97122
123+ /** Post a low-priority notification informing the user that the foreground service was
124+ * timed out by the system — the run continues but may be killed by Doze without warning. */
125+ @SuppressLint("MissingPermission")
126+ private fun postTimeoutNotification() {
127+ runCatching {
128+ val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
129+ .setSmallIcon(R.drawable.ic_launcher_foreground)
130+ .setContentTitle(getString(R.string.notif_running_title))
131+ .setContentText(getString(R.string.notif_fg_timeout_text))
132+ .setAutoCancel(true)
133+ .setPriority(NotificationCompat.PRIORITY_LOW)
134+ .build()
135+ NotificationManagerCompat.from(this).notify(NOTIF_TIMEOUT_ID, notification)
136+ }
137+ }
138+
98139 override fun onDestroy() {
99140 // Safety net: if the process is being killed while a run is in progress,
100141 // ensure the notification is removed rather than lingering.
@@ -107,15 +148,20 @@ class RunForegroundService : Service() {
107148 companion object {
108149 private const val TAG = "RunForegroundService"
109150 private const val NOTIF_ID = 1
151+ private const val NOTIF_TIMEOUT_ID = 2
110152 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
153+ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
154+ const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
111155
112− fun start(context: Context, sessionTitle: String? = null) {
156+ fun start(context: Context, sessionTitle: String? = null, sessionId: String? = null, progress: String? = null) {
113157 // startForegroundService can throw ForegroundServiceStartNotAllowedException
114158 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
115159 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
116160 runCatching {
117161 val intent = Intent(context, RunForegroundService::class.java)
118162 sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
163+ sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
164+ progress?.let { intent.putExtra(EXTRA_PROGRESS, it) }
119165 context.startForegroundService(intent)
120166 }.onFailure {
121167 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+68
@@ -125,6 +125,22 @@ object SessionNotifications {
125125 replyPending,
126126 ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
127127
128+ // "Mark read" action so the user can triage a completion without opening the session.
129+ val markReadIntent = Intent(context, NotificationActionReceiver::class.java).apply {
130+ action = NotificationActionReceiver.ACTION_MARK_READ
131+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
132+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
133+ }
134+ val markReadPending = PendingIntent.getBroadcast(
135+ context, notifId + 100, markReadIntent,
136+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
137+ )
138+ val markReadAction = NotificationCompat.Action.Builder(
139+ R.drawable.ic_launcher_foreground,
140+ context.getString(R.string.notif_action_mark_read),
141+ markReadPending,
142+ ).build()
143+
128144 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
129145 .setSmallIcon(R.drawable.ic_launcher_foreground)
130146 .setContentTitle(context.getString(R.string.notif_completed_title))
@@ -135,6 +151,7 @@ object SessionNotifications {
135151 .setAutoCancel(true)
136152 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
137153 .addAction(replyAction)
154+ .addAction(markReadAction)
138155 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
139156 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
140157 // Group notifications from the same server so multiple completions collapse into
@@ -192,6 +209,7 @@ object SessionNotifications {
192209 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
193210 .setCategory(NotificationCompat.CATEGORY_CALL)
194211 .setPriority(NotificationCompat.PRIORITY_HIGH)
212+ .setGroup(GROUP_PERMISSION)
195213
196214 // One action per response. Each PendingIntent needs a distinct request code or
197215 // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
@@ -216,6 +234,10 @@ object SessionNotifications {
216234
217235 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
218236 .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
237+ // Group permission notifications so a multi-session burst collapses into a stackable
238+ // group instead of filling the shade — mirroring the completed-sessions grouping.
239+ runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
240+ .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
219241 }
220242
221243 /** Post a notification when a background run fails. */
@@ -231,9 +253,34 @@ object SessionNotifications {
231253 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
232254 .setCategory(NotificationCompat.CATEGORY_ERROR)
233255 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
256+ // Group error notifications so a burst of failures collapses into a stackable group.
257+ .setGroup(GROUP_ERROR)
234258 .build()
235259 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
236260 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
261+ runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
262+ .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
263+ }
264+
265+ /** Update the completion notification to show a brief "Sent" confirmation after a
266+ * successful inline reply, replacing the completion notification's content. Auto-cancels
267+ * on tap so it doesn't linger. */
268+ @SuppressLint("MissingPermission")
269+ fun postReplySent(context: Context, sessionId: String) {
270+ if (!canPost(context)) {
271+ cancel(context, sessionId)
272+ return
273+ }
274+ val notifId = notifId(NS_COMPLETED, sessionId)
275+ val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
276+ .setSmallIcon(R.drawable.ic_launcher_foreground)
277+ .setContentTitle(context.getString(R.string.notif_reply_sent))
278+ .setAutoCancel(true)
279+ .setPriority(NotificationCompat.PRIORITY_LOW)
280+ .setGroup(GROUP_COMPLETED)
281+ .build()
282+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
283+ .onFailure { Log.w(TAG, "Failed to post reply-sent notification", it) }
237284 }
238285
239286 /** An inline reply couldn't be durably enqueued (no resolvable profile, or the outbox write
@@ -280,5 +327,26 @@ object SessionNotifications {
280327 }
281328
282329 private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
330+ private const val GROUP_PERMISSION = "soy.iko.opencode.PERMISSION"
331+ private const val GROUP_ERROR = "soy.iko.opencode.ERROR"
283332 private const val SUMMARY_COMPLETED_ID = -1
333+ private const val SUMMARY_PERMISSION_ID = -2
334+ private const val SUMMARY_ERROR_ID = -3
335+
336+ /** Post a summary notification for a group so multiple entries collapse into one stackable
337+ * entry in the shade instead of filling it. */
338+ @SuppressLint("MissingPermission")
339+ private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
340+ if (!canPost(context)) return
341+ val summary = NotificationCompat.Builder(context, channelId)
342+ .setSmallIcon(R.drawable.ic_launcher_foreground)
343+ .setContentTitle(context.getString(R.string.app_name))
344+ .setAutoCancel(true)
345+ .setGroup(group)
346+ .setGroupSummary(true)
347+ .build()
348+ runCatching {
349+ NotificationManagerCompat.from(context).notify(summaryId, summary)
350+ }.onFailure { Log.w(TAG, "Failed to post group summary notification", it) }
351+ }
284352}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+48−7
@@ -1,5 +1,7 @@
11package soy.iko.opencode.ui
22
3+import androidx.compose.animation.EnterTransition
4+import androidx.compose.animation.ExitTransition
35import androidx.compose.animation.core.tween
46import androidx.compose.animation.fadeIn
57import androidx.compose.animation.fadeOut
@@ -33,6 +35,7 @@ import soy.iko.opencode.ui.server.ServerListScreen
3335import soy.iko.opencode.ui.search.GlobalSearchScreen
3436import soy.iko.opencode.ui.components.LocalChatTextScale
3537import soy.iko.opencode.ui.components.LocalCodeWrap
38+import soy.iko.opencode.ui.components.isReducedMotion
3639import soy.iko.opencode.ui.session.SessionListScreen
3740import soy.iko.opencode.ui.session.TwoPaneSessionChat
3841import soy.iko.opencode.ui.settings.DiagnosticsScreen
@@ -49,6 +52,7 @@ fun OpencodeApp(container: AppContainer) {
4952 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
5053 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
5154 val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
55+ val pendingDiagnostics by container.pendingDiagnostics.collectAsStateWithLifecycle()
5256 val connection by container.activeConnection.collectAsStateWithLifecycle()
5357 // Chat presentation preferences, provided to the whole nav graph so the markdown/code
5458 // renderers honor the user's text-size and code-wrap choices everywhere.
@@ -100,6 +104,14 @@ fun OpencodeApp(container: AppContainer) {
100104 }
101105 }
102106
107+ // Crash-relaunch prompt: navigate to the Diagnostics screen when the user taps
108+ // "View report" on the cold-start crash dialog. Doesn't require a connection.
109+ LaunchedEffect(pendingDiagnostics) {
110+ if (!pendingDiagnostics) return@LaunchedEffect
111+ container.consumePendingDiagnostics()
112+ navController.navigate(Routes.DIAGNOSTICS) { launchSingleTop = true }
113+ }
114+
103115 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
104116 // session and open it. The pending counter is consumed only *after* createSession
105117 // succeeds, so a transient failure leaves the request pending to retry (on the next
@@ -136,6 +148,9 @@ fun OpencodeApp(container: AppContainer) {
136148 LocalChatTextScale provides clampedChatTextScale,
137149 LocalCodeWrap provides codeWrap,
138150 ) {
151+ // Capture the reduced-motion preference once (in the composable scope) so the
152+ // non-composable NavHost transition lambdas can use it to skip animations.
153+ val reducedMotion = isReducedMotion()
139154 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
140155 // the open conversation side by side instead of a single-pane back stack.
141156 BoxWithConstraints {
@@ -179,17 +194,43 @@ fun OpencodeApp(container: AppContainer) {
179194 NavHost(
180195 navController = navController,
181196 startDestination = Routes.SERVERS,
182− // Slide horizontally for forward/back navigation to feel native; fade for
183− // the root so the first frame doesn't slide in from off-screen.
197+ // Motion-aware transitions: respect the user's reduced-motion setting
198+ // (Developer Options → animator scale 0) by skipping animations entirely.
199+ // Per-destination tuning: a chat→chat navigation (switching peer conversations
200+ // within the same surface) crossfades instead of sliding, so it reads as a peer
201+ // switch rather than drilling into a new section. Top-level destinations and
202+ // detail pushes keep the horizontal slide.
184203 enterTransition = {
185− slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
204+ if (reducedMotion) {
205+ EnterTransition.None
206+ } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
186207 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
208+ } else {
209+ slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
210+ fadeIn(tween(NetworkConfig.motionFadeDurationMs))
211+ }
187212 },
188− exitTransition = { fadeOut(tween(NetworkConfig.motionFadeDurationMs)) },
189− popEnterTransition = { fadeIn(tween(NetworkConfig.motionFadeDurationMs)) },
190− popExitTransition = {
191− slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
213+ exitTransition = {
214+ if (reducedMotion) {
215+ ExitTransition.None
216+ } else {
192217 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
218+ }
219+ },
220+ popEnterTransition = {
221+ if (reducedMotion) {
222+ EnterTransition.None
223+ } else {
224+ fadeIn(tween(NetworkConfig.motionFadeDurationMs))
225+ }
226+ },
227+ popExitTransition = {
228+ if (reducedMotion) {
229+ ExitTransition.None
230+ } else {
231+ slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
232+ fadeOut(tween(NetworkConfig.motionFadeDurationMs))
233+ }
193234 },
194235 ) {
195236
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+67−25
@@ -27,7 +27,9 @@ import androidx.compose.foundation.layout.navigationBars
2727import androidx.compose.foundation.layout.padding
2828import androidx.compose.foundation.layout.size
2929import androidx.compose.foundation.layout.union
30+import androidx.compose.foundation.layout.widthIn
3031import androidx.compose.foundation.layout.windowInsetsPadding
32+import androidx.compose.foundation.layout.wrapContentWidth
3133import androidx.compose.foundation.lazy.LazyColumn
3234import androidx.compose.foundation.lazy.items
3335import androidx.compose.foundation.lazy.rememberLazyListState
@@ -41,6 +43,7 @@ import androidx.compose.material.icons.filled.Description
4143import androidx.compose.material.icons.filled.Edit
4244import androidx.compose.material.icons.filled.Expand
4345import androidx.compose.material.icons.filled.KeyboardArrowDown
46+import androidx.compose.material.icons.filled.KeyboardArrowUp
4447import androidx.compose.material.icons.filled.Mic
4548import androidx.compose.material.icons.filled.MoreVert
4649import androidx.compose.material.icons.filled.PhotoCamera
@@ -55,6 +58,7 @@ import androidx.compose.material3.Badge
5558import androidx.compose.material3.BadgedBox
5659import androidx.compose.material3.Button
5760import androidx.compose.material3.CircularProgressIndicator
61+import androidx.compose.material3.SmallFloatingActionButton
5862import androidx.compose.material3.DropdownMenu
5963import androidx.compose.material3.DropdownMenuItem
6064import androidx.compose.material3.ExperimentalMaterial3Api
@@ -713,6 +717,7 @@ fun ChatScreen(
713717 // recompose only this subtree, not the top bar / input bar / 20+ other
714718 // state reads in the ChatScreen body.
715719 val messages by vm.messages.collectAsStateWithLifecycle()
720+ val optimisticStatuses by vm.optimisticStatuses.collectAsStateWithLifecycle()
716721 val listState = rememberLazyListState()
717722 val contentScope = rememberCoroutineScope()
718723 val timeTick = rememberRelativeTimeTick()
@@ -860,20 +865,36 @@ fun ChatScreen(
860865 // separate "Not connected" state below).
861866 onRetry = { vm.refreshMessages() },
862867 )
868+ // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
869+ // recovery is a swipe away everywhere — not just when the message list is
870+ // populated. Previously PTR was only around the populated list, leaving the
871+ // error/loading states reachable only via a button tap.
872+ PullToRefreshBox(
873+ isRefreshing = refreshing,
874+ onRefresh = { vm.refreshMessages() },
875+ // Cap width and center on large screens for readability — full-width
876+ // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
877+ modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
878+ .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
879+ ) {
880+ // Reserve top space when the banner is visible so it doesn't overlap the
881+ // first message / state content.
882+ val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
883+ val topPad = if (bannerVisible) 44.dp else 16.dp
863884 if (loading && messages.isEmpty()) {
864885 val loadingLabel = stringResource(R.string.loading)
865886 CircularProgressIndicator(
866− Modifier
867− .align(Alignment.Center)
868− .semantics { contentDescription = loadingLabel },
887+ Modifier.align(Alignment.Center).padding(top = topPad),
888+ strokeWidth = 2.dp,
869889 )
890+ Box(modifier = Modifier.semantics { contentDescription = loadingLabel })
870891 } else if (loadError && messages.isEmpty()) {
871892 // A failed load with nothing to show. Distinct from the empty
872893 // conversation state below — the conversation isn't empty, it
873894 // failed to load, and offering "Start a conversation" here is
874895 // misleading. Offer a Retry instead, mirroring SessionListScreen.
875896 Column(
876− modifier = Modifier.align(Alignment.Center).padding(24.dp),
897+ modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
877898 horizontalAlignment = Alignment.CenterHorizontally,
878899 ) {
879900 Text(
@@ -891,7 +912,7 @@ fun ChatScreen(
891912 // surface a clear starting state until the first part streams in.
892913 val workingText = stringResource(R.string.working)
893914 Column(
894− modifier = Modifier.align(Alignment.Center).padding(24.dp),
915+ modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
895916 horizontalAlignment = Alignment.CenterHorizontally,
896917 ) {
897918 CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
@@ -906,19 +927,9 @@ fun ChatScreen(
906927 // sending, instead of having to manually tap the field.
907928 runCatching { inputFocusRequester.requestFocus() }
908929 },
909− modifier = Modifier.align(Alignment.Center),
930+ modifier = Modifier.align(Alignment.Center).padding(top = topPad),
910931 )
911932 } else {
912− // Pull-to-refresh wraps the message list so the user can force an SSE
913− // reconnect + re-seed with the same gesture they use on the session and
914− // file lists, instead of hunting for the top-bar refresh icon. The empty
915− // / loading / error states above stay outside the PTR box since there's
916− // no list content to pull against.
917− PullToRefreshBox(
918− isRefreshing = refreshing,
919− onRefresh = { vm.refreshMessages() },
920− modifier = Modifier.fillMaxSize(),
921− ) {
922933 val lastMessageId = messages.lastOrNull()?.info?.id
923934 // Text-to-speech for reading assistant replies aloud; shuts down when the
924935 // message list leaves composition.
@@ -928,10 +939,11 @@ fun ChatScreen(
928939 state = listState,
929940 modifier = Modifier.fillMaxSize(),
930941 // Extra bottom padding reserves room for the jump-to-latest FAB so it
931− // never floats over the last message.
942+ // never floats over the last message. Top padding grows when the
943+ // connection banner is visible so it doesn't overlap the first message.
932944 contentPadding = PaddingValues(
933945 start = 16.dp,
934− top = 16.dp,
946+ top = topPad,
935947 end = 16.dp,
936948 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
937949 ),
@@ -985,6 +997,11 @@ fun ChatScreen(
985997 runCatching { inputFocusRequester.requestFocus() }
986998 },
987999 onBranch = { text -> vm.branchFrom(text) },
1000+ sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1001+ if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1002+ else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1003+ },
1004+ isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
9881005 )
9891006 }
9901007 }
@@ -1006,6 +1023,7 @@ fun ChatScreen(
10061023 }
10071024 }
10081025 }
1026+ }
10091027 // Jump-to-latest affordance when the user has scrolled away during a stream.
10101028 AnimatedVisibility(
10111029 visible = !isPinnedToBottom && listItems.isNotEmpty(),
@@ -1039,6 +1057,27 @@ fun ChatScreen(
10391057 )
10401058 }
10411059 }
1060+ // Scroll-to-top affordance: a small FAB at the start edge, visible when the
1061+ // user has scrolled down past the first few messages. Symmetric to the
1062+ // jump-to-latest FAB, for long conversations where manual fling back is tedious.
1063+ val scrolledDown by remember {
1064+ derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 200 }
1065+ }
1066+ AnimatedVisibility(
1067+ visible = scrolledDown && listItems.size > 4,
1068+ modifier = Modifier.align(Alignment.BottomStart),
1069+ ) {
1070+ SmallFloatingActionButton(
1071+ onClick = {
1072+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } }
1073+ },
1074+ modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
1075+ ) {
1076+ Icon(
1077+ Icons.Filled.KeyboardArrowUp,
1078+ contentDescription = stringResource(R.string.scroll_to_top),
1079+ )
1080+ }
10421081 }
10431082 }
10441083 }
@@ -1354,15 +1393,18 @@ private fun ChatInputBar(
13541393 }
13551394 },
13561395 keyboardOptions = KeyboardOptions(
1357− // Always expose the platform default (newline) IME action so a soft-
1358− // keyboard user can insert a newline even when "Send on Enter" is on —
1359− // soft keyboards have no Shift key, so the hardware Shift+Enter escape
1360− // hatch doesn't apply. The trailing Send button and the hardware Enter
1361− // (onPreviewKeyEvent above) are the send affordances; the soft IME
1362− // action is the newline affordance.
1363− imeAction = ImeAction.Default,
1396+ // When "Send on Enter" is on, expose ImeAction.Send so the soft
1397+ // keyboard shows a send key — matching the setting's intent and
1398+ // giving touch-only users a keyboard send affordance. When off,
1399+ // ImeAction.Default keeps the enter/newline key for multi-line input.
1400+ imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
13641401 capitalization = KeyboardCapitalization.Sentences,
13651402 ),
1403+ keyboardActions = KeyboardActions(
1404+ onSend = {
1405+ if (enabled && hasContent) onSend()
1406+ },
1407+ ),
13661408 )
13671409 ComposerTrailingButton(
13681410 running = running,
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+119−20
@@ -7,6 +7,7 @@ import soy.iko.opencode.data.model.Command
77import soy.iko.opencode.data.model.FilePromptPart
88import soy.iko.opencode.data.model.MessageWithParts
99import soy.iko.opencode.data.model.ModelOption
10+import soy.iko.opencode.data.model.UserMessage
1011import soy.iko.opencode.data.model.Permission
1112import soy.iko.opencode.data.model.PermissionReplied
1213import soy.iko.opencode.data.model.PermissionResponse
@@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.asStateFlow
3839import kotlinx.coroutines.flow.receiveAsFlow
3940import kotlinx.coroutines.CancellationException
4041import kotlinx.coroutines.flow.collectLatest
42+import kotlinx.coroutines.flow.combine
4143import kotlinx.coroutines.flow.debounce
4244import kotlinx.coroutines.flow.distinctUntilChanged
4345import kotlinx.coroutines.flow.drop
@@ -166,7 +168,15 @@ class ChatViewModel(
166168 private val _agentsReload = MutableStateFlow(0)
167169 private val _commandsReload = MutableStateFlow(0)
168170
169− val messages: StateFlow<List<MessageWithParts>> =
171+ /** An optimistically-injected user message shown immediately on send, before the server
172+ * echoes the real message back via SSE. Removed once a matching UserMessage arrives. */
173+ data class OptimisticEntry(val tempId: String, val text: String, val timestamp: Long, val failed: Boolean)
174+
175+ private val _optimisticMessages = MutableStateFlow<List<OptimisticEntry>>(emptyList())
176+
177+ /** SSE-driven message stream with retry/backoff and loading/error side effects. Combined
178+ * with [_optimisticMessages] by [messages] so the user sees their outgoing prompt instantly. */
179+ private val sseMessages: Flow<List<MessageWithParts>> =
170180 container.activeConnection
171181 .flatMapLatest { conn ->
172182 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
@@ -185,6 +195,10 @@ class ChatViewModel(
185195 if (it.isNotEmpty()) {
186196 hasShownMessages = true
187197 }
198+ // Reconcile optimistic messages: drop any whose text now matches a real SSE
199+ // UserMessage (the server echoed the prompt back). Matching by trimmed text,
200+ // removing the oldest match first so duplicate texts are handled in order.
201+ reconcileOptimistic(it)
188202 }
189203 .retryWhen { cause, attempt ->
190204 _loading.value = false
@@ -209,11 +223,15 @@ class ChatViewModel(
209223 delay(backoffMs)
210224 true
211225 }
212− .stateIn(
213− scope = viewModelScope,
214− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
215− initialValue = emptyList(),
216− )
226+
227+ val messages: StateFlow<List<MessageWithParts>> =
228+ combine(sseMessages, _optimisticMessages) { sse, optimistic ->
229+ mergeOptimistic(sse, optimistic)
230+ }.stateIn(
231+ scope = viewModelScope,
232+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
233+ initialValue = emptyList(),
234+ )
217235
218236 /** Whether the conversation has any messages. Derived separately so the top bar
219237 * (share button enabled state) can observe this cheap boolean instead of the
@@ -227,6 +245,67 @@ class ChatViewModel(
227245 initialValue = false,
228246 )
229247
248+ /** Map of optimistic message tempId → failed flag, so the UI can render a "Sending…"
249+ * or "Failed to send" indicator on the corresponding message bubble. */
250+ val optimisticStatuses: StateFlow<Map<String, Boolean>> = _optimisticMessages
251+ .map { entries -> entries.associate { it.tempId to it.failed } }
252+ .stateIn(
253+ scope = viewModelScope,
254+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
255+ initialValue = emptyMap(),
256+ )
257+
258+ /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
259+ * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
260+ * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
261+ * are kept so the user sees the failed indicator until they retry or dismiss. */
262+ private fun mergeOptimistic(
263+ sse: List<MessageWithParts>,
264+ optimistic: List<OptimisticEntry>,
265+ ): List<MessageWithParts> {
266+ if (optimistic.isEmpty()) return sse
267+ // Collect the text of real user messages so optimistic entries can be matched.
268+ val realUserTexts = mutableSetOf<String>()
269+ for (msg in sse) {
270+ if (msg.info !is UserMessage) continue
271+ val text = msg.parts.filterIsInstance<TextPart>()
272+ .joinToString("\n\n") { it.text }
273+ .trim()
274+ if (text.isNotEmpty()) realUserTexts.add(text)
275+ }
276+ val surviving = optimistic.filter { it.text.trim() !in realUserTexts }
277+ if (surviving.isEmpty()) return sse
278+ return sse + surviving.map { it.toMessageWithParts() }
279+ }
280+
281+ /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
282+ * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly. */
283+ private fun reconcileOptimistic(sse: List<MessageWithParts>) {
284+ if (_optimisticMessages.value.isEmpty()) return
285+ val realUserTexts = mutableSetOf<String>()
286+ for (msg in sse) {
287+ if (msg.info !is UserMessage) continue
288+ val text = msg.parts.filterIsInstance<TextPart>()
289+ .joinToString("\n\n") { it.text }
290+ .trim()
291+ if (text.isNotEmpty()) realUserTexts.add(text)
292+ }
293+ if (realUserTexts.isEmpty()) return
294+ _optimisticMessages.update { entries ->
295+ entries.filterNot { it.text.trim() in realUserTexts }
296+ }
297+ }
298+
299+ /** Convert an optimistic entry to a [MessageWithParts] for display. */
300+ private fun OptimisticEntry.toMessageWithParts(): MessageWithParts = MessageWithParts(
301+ info = UserMessage(
302+ id = tempId,
303+ sessionID = sessionId,
304+ time = soy.iko.opencode.data.model.TimeInfo(created = timestamp),
305+ ),
306+ parts = listOf(TextPart(id = "$tempId-text", text = text)),
307+ )
308+
230309 /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
231310 * live progress checklist above the composer. distinctUntilChanged so a per-token messages
232311 * emission that doesn't change the plan doesn't recompose the bar. */
@@ -786,8 +865,10 @@ class ChatViewModel(
786865 while (currentCoroutineContext().isActive) {
787866 try {
788867 conn.events.state.collect { state ->
789− if ((state == EventStreamClient.ConnectionState.Disconnected ||
790− state == EventStreamClient.ConnectionState.Failed) && _running.value) {
868+ val isDown = state == EventStreamClient.ConnectionState.Disconnected ||
869+ state == EventStreamClient.ConnectionState.Failed ||
870+ state == EventStreamClient.ConnectionState.AuthFailed
871+ if (isDown && _running.value) {
791872 _running.value = false
792873 }
793874 }
@@ -863,6 +944,16 @@ class ChatViewModel(
863944 suppressDraftPersist.set(true)
864945 _draft.value = ""
865946 }
947+ // Inject an optimistic user message so the outgoing prompt is visible immediately,
948+ // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
949+ // real UserMessage arrives. Only for text-bearing prompts (an image-only prompt has
950+ // no text to show).
951+ val optimisticId = if (trimmed.isNotEmpty()) "__optimistic_${System.nanoTime()}" else null
952+ if (optimisticId != null) {
953+ // Clear any prior failed optimistic entry with the same text (a retry re-sends).
954+ _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
955+ _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
956+ }
866957 viewModelScope.launch {
867958 runCatchingCancellable {
868959 conn.repository.sendPrompt(
@@ -874,6 +965,13 @@ class ChatViewModel(
874965 idempotencyKey = key,
875966 )
876967 }.onFailure {
968+ // Mark the optimistic message as failed so the user sees a failure indicator
969+ // instead of a perpetual "sending" state.
970+ if (optimisticId != null) {
971+ _optimisticMessages.update { entries ->
972+ entries.map { if (it.tempId == optimisticId) it.copy(failed = true) else it }
973+ }
974+ }
877975 suppressDraftPersist.set(false)
878976 _failedDraft.value = trimmed
879977 failedIdempotencyKey = key
@@ -1326,18 +1424,19 @@ class ChatViewModel(
13261424 * already viewing the session, so a confirmation dialog guards the action instead. */
13271425 fun deleteSession() {
13281426 val conn = connection ?: return
1329− viewModelScope.launch {
1330− runCatchingCancellable { conn.repository.deleteSession(sessionId) }
1331− .onSuccess {
1332− container.draftStore.remove(sessionId)
1333− container.attachmentDraftStore.remove(sessionId)
1334− container.messageCacheStore.remove(conn.profile.id, sessionId)
1335− container.outboxStore.removeForSession(sessionId)
1336− container.clearUnread(sessionId)
1337− _sessionDeleted.value = true
1338− }
1339− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1340− }
1427+ // Schedule a deferred delete with an undo window (matching SessionListViewModel's
1428+ // pattern), so the user can undo from the session list after navigating back. The
1429+ // actual REST delete runs after undoDeleteDelayMs; cancel via cancelSessionDelete.
1430+ container.scheduleSessionDelete(
1431+ id = sessionId,
1432+ delayMs = NetworkConfig.undoDeleteDelayMs,
1433+ onDeleted = { /* cleanup handled by container.scheduleSessionDelete */ },
1434+ onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1435+ )
1436+ // Let the SessionListScreen show the undo snackbar for this externally-initiated delete.
1437+ container.emitExternalSessionUndo(sessionId)
1438+ // Navigate back immediately so the user lands on the session list where the Undo shows.
1439+ _sessionDeleted.value = true
13411440 }
13421441
13431442 /** Reconnect to the most recently used server profile (used when the connection is gone). */
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+99−27
@@ -1,7 +1,7 @@
11package soy.iko.opencode.ui.chat
22
33import androidx.compose.foundation.background
4−import androidx.compose.foundation.combinedClickable
4+import androidx.compose.foundation.gestures.detectTapGestures
55import androidx.compose.foundation.layout.Arrangement
66import androidx.compose.foundation.layout.Box
77import androidx.compose.foundation.layout.BoxWithConstraints
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.automirrored.filled.VolumeUp
1717import androidx.compose.material.icons.filled.CallSplit
1818import androidx.compose.material.icons.filled.ContentCopy
1919import androidx.compose.material.icons.filled.Edit
20+import androidx.compose.material.icons.filled.Error
2021import androidx.compose.material.icons.filled.FormatQuote
2122import androidx.compose.material.icons.filled.MoreVert
2223import androidx.compose.material.icons.filled.Restore
@@ -24,6 +25,7 @@ import androidx.compose.material.icons.filled.SmartToy
2425import androidx.compose.material.icons.filled.StopCircle
2526import androidx.compose.material3.DropdownMenu
2627import androidx.compose.material3.DropdownMenuItem
28+import androidx.compose.material3.CircularProgressIndicator
2729import androidx.compose.material3.Icon
2830import androidx.compose.material3.IconButton
2931import androidx.compose.material3.MaterialTheme
@@ -38,10 +40,10 @@ import androidx.compose.ui.Alignment
3840import androidx.compose.ui.Modifier
3941import androidx.compose.ui.draw.clip
4042import androidx.compose.ui.hapticfeedback.HapticFeedbackType
43+import androidx.compose.ui.input.pointer.pointerInput
4144import androidx.compose.ui.platform.LocalContext
4245import androidx.compose.ui.platform.LocalHapticFeedback
4346import androidx.compose.ui.res.stringResource
44−import androidx.compose.ui.semantics.Role
4547import androidx.compose.ui.unit.dp
4648import soy.iko.opencode.R
4749import soy.iko.opencode.data.model.AssistantMessage
@@ -72,6 +74,9 @@ fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String
7274 ?: id
7375}
7476
77+/** Status of an outgoing message, shown as a small indicator on the user bubble. */
78+enum class MessageSendStatus { SENDING, FAILED }
79+
7580/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
7681@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
7782@Composable
@@ -88,11 +93,13 @@ fun MessageBubble(
8893 isSpeaking: Boolean = false,
8994 onQuote: ((String) -> Unit)? = null,
9095 onBranch: ((String) -> Unit)? = null,
96+ sendStatus: MessageSendStatus? = null,
97+ isEdited: Boolean = false,
9198) {
9299 when (message.info) {
93− is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch)
100+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited)
94101 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
95− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote)
102+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote, onBranch, isEdited)
96103 }
97104}
98105
@@ -265,14 +272,19 @@ private fun UserBubble(
265272 onEdit: ((String) -> Unit)? = null,
266273 onQuote: ((String) -> Unit)? = null,
267274 onBranch: ((String) -> Unit)? = null,
275+ sendStatus: MessageSendStatus? = null,
276+ isEdited: Boolean = false,
268277) {
269278 val context = LocalContext.current
270279 val haptics = LocalHapticFeedback.current
280+ val density = androidx.compose.ui.platform.LocalDensity.current
271281 val copyLabel = stringResource(R.string.copy)
272282 val editLabel = stringResource(R.string.edit_message)
273283 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
274284 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
285+ // The menu anchors at the actual touch point rather than a fixed corner.
275286 var longPressMenu by remember { mutableStateOf(false) }
287+ var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
276288 // Collect text from all TextParts for copying, so a user can reuse/repost their
277289 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
278290 val textToCopy = remember(message.parts) {
@@ -305,21 +317,65 @@ private fun UserBubble(
305317 }
306318 // Footer row with timestamp + inline actions. Long-press opens a context menu
307319 // consolidating copy/quote/branch/revert — the conventional Android pattern — so
308− // the actions are discoverable without spotting the 18dp inline icons.
320+ // the actions are discoverable without spotting the 18dp inline icons. Uses
321+ // pointerInput/detectTapGestures (not combinedClickable) so no misleading "Button"
322+ // semantics are announced to TalkBack for an area whose tap does nothing, and the
323+ // menu anchors at the actual long-press position instead of a fixed corner.
309324 Box {
310325 Row(
311− modifier = Modifier.fillMaxWidth().combinedClickable(
312− role = Role.Button,
313− onClick = {},
314− onLongClick = {
315− haptics.performHapticFeedback(HapticFeedbackType.LongPress)
316− longPressMenu = true
326+ modifier = Modifier
327+ .fillMaxWidth()
328+ .pointerInput(Unit) {
329+ detectTapGestures(onLongPress = { offset ->
330+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
331+ longPressOffset = androidx.compose.ui.unit.DpOffset(
332+ with(density) { offset.x.toDp() },
333+ with(density) { offset.y.toDp() },
334+ )
335+ longPressMenu = true
336+ })
317337 },
318− ),
319338 horizontalArrangement = Arrangement.SpaceBetween,
320339 verticalAlignment = Alignment.CenterVertically,
321340 ) {
322− MessageTimestampText(message.info)
341+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
342+ when (sendStatus) {
343+ MessageSendStatus.SENDING -> {
344+ CircularProgressIndicator(
345+ Modifier.size(12.dp),
346+ strokeWidth = 1.5.dp,
347+ color = MaterialTheme.colorScheme.onSurfaceVariant,
348+ )
349+ Text(
350+ stringResource(R.string.message_sending),
351+ style = MaterialTheme.typography.labelSmall,
352+ color = MaterialTheme.colorScheme.onSurfaceVariant,
353+ )
354+ }
355+ MessageSendStatus.FAILED -> {
356+ Icon(
357+ Icons.Filled.Error,
358+ contentDescription = stringResource(R.string.message_send_failed),
359+ modifier = Modifier.size(12.dp),
360+ tint = MaterialTheme.colorScheme.error,
361+ )
362+ Text(
363+ stringResource(R.string.message_send_failed),
364+ style = MaterialTheme.typography.labelSmall,
365+ color = MaterialTheme.colorScheme.error,
366+ )
367+ }
368+ null -> {}
369+ }
370+ if (isEdited && sendStatus == null) {
371+ Text(
372+ stringResource(R.string.message_edited),
373+ style = MaterialTheme.typography.labelSmall,
374+ color = MaterialTheme.colorScheme.onSurfaceVariant,
375+ )
376+ }
377+ MessageTimestampText(message.info)
378+ }
323379 Row(verticalAlignment = Alignment.CenterVertically) {
324380 // Edit affordance: reload this prompt into the composer, reverting the
325381 // conversation to before it so a Send replaces it. Only when there's text
@@ -371,7 +427,7 @@ private fun UserBubble(
371427 MessageLongPressMenu(
372428 expanded = longPressMenu,
373429 onDismiss = { longPressMenu = false },
374− offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
430+ offset = longPressOffset,
375431 text = textToCopy,
376432 onCopy = onCopy,
377433 onQuote = onQuote,
@@ -396,6 +452,8 @@ private fun AssistantBlock(
396452 onSpeak: ((String) -> Unit)? = null,
397453 isSpeaking: Boolean = false,
398454 onQuote: ((String) -> Unit)? = null,
455+ onBranch: ((String) -> Unit)? = null,
456+ isEdited: Boolean = false,
399457) {
400458 Column(
401459 modifier = modifier.fillMaxWidth(),
@@ -465,6 +523,7 @@ private fun AssistantBlock(
465523 }
466524 // Long-press context menu state (see UserBubble for rationale).
467525 var longPressMenu by remember { mutableStateOf(false) }
526+ var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
468527 // Collect text from all TextParts for copy/read-aloud. Memoized so a
469528 // scroll-induced recomposition doesn't re-scan the parts list.
470529 val textToCopy = remember(message.parts) {
@@ -475,6 +534,7 @@ private fun AssistantBlock(
475534 }
476535 val assistantHaptics = LocalHapticFeedback.current
477536 val assistantContext = LocalContext.current
537+ val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
478538 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
479539 {
480540 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -483,14 +543,18 @@ private fun AssistantBlock(
483543 }
484544 Box {
485545 Row(
486− modifier = Modifier.fillMaxWidth().combinedClickable(
487− role = Role.Button,
488− onClick = {},
489− onLongClick = {
490− assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
491− longPressMenu = true
546+ modifier = Modifier
547+ .fillMaxWidth()
548+ .pointerInput(Unit) {
549+ detectTapGestures(onLongPress = { offset ->
550+ assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
551+ longPressOffset = androidx.compose.ui.unit.DpOffset(
552+ with(assistantDensity) { offset.x.toDp() },
553+ with(assistantDensity) { offset.y.toDp() },
554+ )
555+ longPressMenu = true
556+ })
492557 },
493− ),
494558 horizontalArrangement = Arrangement.SpaceBetween,
495559 verticalAlignment = Alignment.CenterVertically,
496560 ) {
@@ -502,6 +566,13 @@ private fun AssistantBlock(
502566 color = MaterialTheme.colorScheme.onSurfaceVariant,
503567 )
504568 }
569+ if (isEdited) {
570+ Text(
571+ stringResource(R.string.message_edited),
572+ style = MaterialTheme.typography.labelSmall,
573+ color = MaterialTheme.colorScheme.onSurfaceVariant,
574+ )
575+ }
505576 MessageTimestampText(message.info)
506577 }
507578 AssistantActions(
@@ -510,16 +581,17 @@ private fun AssistantBlock(
510581 onSpeak = onSpeak,
511582 onRevert = onRevert,
512583 onQuote = onQuote,
584+ onBranch = onBranch,
513585 )
514586 }
515587 MessageLongPressMenu(
516588 expanded = longPressMenu,
517589 onDismiss = { longPressMenu = false },
518− offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
590+ offset = longPressOffset,
519591 text = textToCopy,
520592 onCopy = onCopy,
521593 onQuote = onQuote,
522− onBranch = null,
594+ onBranch = onBranch,
523595 onRevert = onRevert,
524596 )
525597 }
@@ -538,6 +610,7 @@ private fun AssistantActions(
538610 onSpeak: ((String) -> Unit)?,
539611 onRevert: (() -> Unit)?,
540612 onQuote: ((String) -> Unit)? = null,
613+ onBranch: ((String) -> Unit)? = null,
541614) {
542615 val context = LocalContext.current
543616 val haptics = LocalHapticFeedback.current
@@ -574,10 +647,9 @@ private fun AssistantActions(
574647 )
575648 }
576649 }
577− // Assistant replies can be quoted into the composer (branching is offered only on
578− // user prompts, where seeding a new session with the prompt makes sense).
579− if (textToCopy != null && onQuote != null) {
580− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = null)
650+ // Assistant replies can be quoted into the composer or branched into a new session.
651+ if (textToCopy != null && (onQuote != null || onBranch != null)) {
652+ MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
581653 }
582654 }
583655}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+14−2
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Error
2323import androidx.compose.material.icons.filled.ExpandLess
2424import androidx.compose.material.icons.filled.ExpandMore
2525import androidx.compose.material.icons.filled.Psychology
26+import androidx.compose.material.icons.filled.Schedule
2627import androidx.compose.material3.CircularProgressIndicator
2728import androidx.compose.material3.Icon
2829import androidx.compose.material3.IconButton
@@ -80,7 +81,7 @@ import soy.iko.opencode.ui.components.looksLikeDiff
8081import soy.iko.opencode.ui.components.showCopyToast
8182import soy.iko.opencode.R
8283
83−private const val COLLAPSED_LIMIT = 600
84+private const val COLLAPSED_LIMIT = 4000
8485
8586// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
8687// resilient decoding) so this stays human-readable; constructed lazily and memoized.
@@ -454,7 +455,18 @@ private fun CollapsibleDetail(
454455@Composable
455456private fun ToolStatusIcon(state: ToolState) {
456457 when (state) {
457− is ToolPending, is ToolRunning -> {
458+ is ToolPending -> {
459+ // Queued, not yet executing: a static clock icon distinguishes it from an
460+ // actively-running tool (spinner) so the user can tell what's waiting.
461+ val label = stringResource(R.string.tool_queued)
462+ Icon(
463+ Icons.Filled.Schedule,
464+ contentDescription = label,
465+ modifier = Modifier.size(16.dp),
466+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
467+ )
468+ }
469+ is ToolRunning -> {
458470 val label = stringResource(R.string.tool_running)
459471 CircularProgressIndicator(
460472 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+10−3
@@ -60,7 +60,8 @@ fun ConnectionBanner(
6060 when (state) {
6161 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
6262 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
63− EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
63+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
64+ EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
6465 EventStreamClient.ConnectionState.Connected -> null
6566 }
6667 }
@@ -79,7 +80,13 @@ fun ConnectionBanner(
7980 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
8081 // transient connecting/reconnecting states by switching to the error palette,
8182 // so the banner conveys urgency without relying on text alone.
82− val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed
83+ val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed ||
84+ state == EventStreamClient.ConnectionState.AuthFailed
85+ // Server-side failure (auth or endpoint) — distinct from offline. Used to gate the
86+ // inline Retry button, which only makes sense when the device is online and the server
87+ // rejected the connection (not when the device itself is offline).
88+ val isServerFailure = state == EventStreamClient.ConnectionState.Failed ||
89+ state == EventStreamClient.ConnectionState.AuthFailed
8390 val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
8491 else MaterialTheme.colorScheme.tertiaryContainer
8592 val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
@@ -121,7 +128,7 @@ fun ConnectionBanner(
121128 // leaving the screen. Hidden during transient states (the system is
122129 // already reconnecting), when offline (retry can't help), and when no
123130 // callback is wired (callers that don't have a reconnect path).
124− if (state == EventStreamClient.ConnectionState.Failed && !isOffline && onRetry != null) {
131+ if (isServerFailure && !isOffline && onRetry != null) {
125132 TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
126133 Text(stringResource(R.string.retry_now), color = onContainer)
127134 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+100−124
@@ -217,73 +217,15 @@ fun ServerListScreen(
217217 vm.connect(profile, onConnected)
218218 },
219219 ) {
220− Row(
221− modifier = Modifier.fillMaxWidth().padding(16.dp),
222− verticalAlignment = Alignment.CenterVertically,
223− ) {
224− Column(modifier = Modifier.weight(1f)) {
225− Text(
226− profile.displayLabel,
227− style = MaterialTheme.typography.titleMedium,
228− maxLines = 1,
229− overflow = TextOverflow.Ellipsis,
230− )
231− Text(
232− profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
233− style = MaterialTheme.typography.bodySmall,
234− color = MaterialTheme.colorScheme.onSurfaceVariant,
235− maxLines = 1,
236− overflow = TextOverflow.Ellipsis,
237− )
238− Text(
239− stringResource(R.string.connected),
240− style = MaterialTheme.typography.labelSmall,
241− color = MaterialTheme.colorScheme.primary,
242− )
243− LastUsedText(profile.lastUsed)
244− }
245− Icon(
246− Icons.Filled.CheckCircle,
247− contentDescription = stringResource(R.string.connected),
248− tint = MaterialTheme.colorScheme.primary,
249− modifier = Modifier.size(22.dp),
220+ ServerCardContent(
221+ profile = profile,
222+ isActive = true,
223+ isConnecting = false,
224+ onEdit = { onEditProfile(profile.id) },
225+ onDuplicate = { onDuplicateProfile(profile.id) },
226+ onPendingDelete = { pendingDeleteId = profile.id },
250227 )
251− // Overflow menu consolidates edit/duplicate/delete so the
252− // active row shows a single chevron instead of three icons.
253− // Keep edit available on the active server so credentials can
254− // be fixed without disconnecting first. Delete is also offered:
255− // the confirmation dialog warns that removing the active server
256− // also disconnects (remove_server_active_text), and the
257− // ViewModel disconnects immediately on confirm.
258− var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
259− val editLabel = stringResource(R.string.edit)
260− val duplicateLabel = stringResource(R.string.duplicate_server)
261− val removeLabel = stringResource(R.string.remove)
262− val moreLabel = stringResource(R.string.more)
263− Box {
264− IconButton(onClick = { showRowMenu = true }) {
265− Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
266− }
267− DropdownMenu(
268− expanded = showRowMenu,
269− onDismissRequest = { showRowMenu = false },
270− ) {
271− DropdownMenuItem(
272− text = { Text(editLabel) },
273− onClick = { showRowMenu = false; onEditProfile(profile.id) },
274− )
275− DropdownMenuItem(
276− text = { Text(duplicateLabel) },
277− onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
278− )
279− DropdownMenuItem(
280− text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
281− onClick = { showRowMenu = false; pendingDeleteId = profile.id },
282− )
283− }
284− }
285228 }
286− }
287229 } else {
288230 // Swipe end-to-start reveals a delete affordance and opens the
289231 // same confirmation dialog as the trash icon (matching the
@@ -326,65 +268,14 @@ fun ServerListScreen(
326268 vm.connect(profile, onConnected)
327269 },
328270 ) {
329− Row(
330− modifier = Modifier.fillMaxWidth().padding(16.dp),
331− verticalAlignment = Alignment.CenterVertically,
332− ) {
333− Column(modifier = Modifier.weight(1f)) {
334− Text(
335− profile.displayLabel,
336− style = MaterialTheme.typography.titleMedium,
337− maxLines = 1,
338− overflow = TextOverflow.Ellipsis,
339− )
340− Text(
341− profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
342− style = MaterialTheme.typography.bodySmall,
343− color = MaterialTheme.colorScheme.onSurfaceVariant,
344− maxLines = 1,
345− overflow = TextOverflow.Ellipsis,
346− )
347− LastUsedText(profile.lastUsed)
348− }
349− if (connectingId == profile.id) {
350− val connectingLabel = stringResource(R.string.connecting)
351− CircularProgressIndicator(
352− modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
353− )
354− } else {
355− // Overflow menu replaces the three inline edit/duplicate/
356− // delete icons so a long server list looks less busy.
357− // Swipe-to-delete remains the gesture path; the overflow
358− // is the discovery path (matching the session cards).
359− var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
360− val editLabel = stringResource(R.string.edit)
361− val duplicateLabel = stringResource(R.string.duplicate_server)
362− val removeLabel = stringResource(R.string.remove)
363− val moreLabel = stringResource(R.string.more)
364− Box {
365− IconButton(onClick = { showRowMenu = true }) {
366− Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
367− }
368− DropdownMenu(
369− expanded = showRowMenu,
370− onDismissRequest = { showRowMenu = false },
371− ) {
372− DropdownMenuItem(
373− text = { Text(editLabel) },
374− onClick = { showRowMenu = false; onEditProfile(profile.id) },
375− )
376− DropdownMenuItem(
377− text = { Text(duplicateLabel) },
378− onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
379− )
380− DropdownMenuItem(
381− text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
382− onClick = { showRowMenu = false; pendingDeleteId = profile.id },
383− )
384− }
385− }
386− }
387− }
271+ ServerCardContent(
272+ profile = profile,
273+ isActive = false,
274+ isConnecting = connectingId == profile.id,
275+ onEdit = { onEditProfile(profile.id) },
276+ onDuplicate = { onDuplicateProfile(profile.id) },
277+ onPendingDelete = { pendingDeleteId = profile.id },
278+ )
388279 }
389280 }
390281 }
@@ -422,6 +313,91 @@ fun ServerListScreen(
422313 }
423314}
424315
316+/** Shared content for a server profile card — used by both the active-row (plain Card)
317+ * and non-active-row (SwipeToDismissBox) branches, eliminating ~60 lines of duplication.
318+ * Shows the label, URL, optional "Connected" label, last-used time, and a trailing area
319+ * with a check icon (active), connecting spinner, or overflow menu. */
320+@Composable
321+private fun ServerCardContent(
322+ profile: ServerProfile,
323+ isActive: Boolean,
324+ isConnecting: Boolean,
325+ onEdit: () -> Unit,
326+ onDuplicate: () -> Unit,
327+ onPendingDelete: () -> Unit,
328+) {
329+ Row(
330+ modifier = Modifier.fillMaxWidth().padding(16.dp),
331+ verticalAlignment = Alignment.CenterVertically,
332+ ) {
333+ Column(modifier = Modifier.weight(1f)) {
334+ Text(
335+ profile.displayLabel,
336+ style = MaterialTheme.typography.titleMedium,
337+ maxLines = 1,
338+ overflow = TextOverflow.Ellipsis,
339+ )
340+ Text(
341+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
342+ style = MaterialTheme.typography.bodySmall,
343+ color = MaterialTheme.colorScheme.onSurfaceVariant,
344+ maxLines = 1,
345+ overflow = TextOverflow.Ellipsis,
346+ )
347+ if (isActive) {
348+ Text(
349+ stringResource(R.string.connected),
350+ style = MaterialTheme.typography.labelSmall,
351+ color = MaterialTheme.colorScheme.primary,
352+ )
353+ }
354+ LastUsedText(profile.lastUsed)
355+ }
356+ if (isActive) {
357+ Icon(
358+ Icons.Filled.CheckCircle,
359+ contentDescription = stringResource(R.string.connected),
360+ tint = MaterialTheme.colorScheme.primary,
361+ modifier = Modifier.size(22.dp),
362+ )
363+ }
364+ if (isConnecting) {
365+ val connectingLabel = stringResource(R.string.connecting)
366+ CircularProgressIndicator(
367+ modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
368+ )
369+ } else {
370+ var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
371+ val editLabel = stringResource(R.string.edit)
372+ val duplicateLabel = stringResource(R.string.duplicate_server)
373+ val removeLabel = stringResource(R.string.remove)
374+ val moreLabel = stringResource(R.string.more)
375+ Box {
376+ IconButton(onClick = { showRowMenu = true }) {
377+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
378+ }
379+ DropdownMenu(
380+ expanded = showRowMenu,
381+ onDismissRequest = { showRowMenu = false },
382+ ) {
383+ DropdownMenuItem(
384+ text = { Text(editLabel) },
385+ onClick = { showRowMenu = false; onEdit() },
386+ )
387+ DropdownMenuItem(
388+ text = { Text(duplicateLabel) },
389+ onClick = { showRowMenu = false; onDuplicate() },
390+ )
391+ DropdownMenuItem(
392+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
393+ onClick = { showRowMenu = false; onPendingDelete() },
394+ )
395+ }
396+ }
397+ }
398+ }
399+}
400+
425401/** "Last used X ago" or "Not used yet" for a server profile, so the user can spot stale
426402 * configs at a glance. Mirrors the relative-time formatting used elsewhere. */
427403@Composable
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+12−3
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.combine
2020import kotlinx.coroutines.flow.stateIn
2121import kotlinx.coroutines.flow.update
2222import kotlinx.coroutines.launch
23+import kotlinx.coroutines.withTimeoutOrNull
2324
2425/** A transient error surfaced as a snackbar, optionally paired with the profile that
2526 * failed to connect so the snackbar can offer a Retry action. */
@@ -66,9 +67,14 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
6667 viewModelScope.launch {
6768 try {
6869 var conn: OpencodeConnection? = null
70+ // Wrap the connect + ping in a timeout so a hung/unresponsive server
71+ // doesn't leave the per-row spinner spinning indefinitely. The ping itself
72+ // also has a REST-level timeout, but the connection establishment path
73+ // (HttpClientFactory.create) can hang on DNS/TCP before any timeout applies.
6974 val result = runCatchingCancellable {
7075 conn = container.connect(profile)
71− conn!!.api.ping()
76+ withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) { conn!!.api.ping() }
77+ ?: throw java.net.SocketTimeoutException("Connect timed out")
7278 }
7379 result.onSuccess { onConnected() }
7480 .onFailure {
@@ -86,10 +92,13 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
8692
8793 /** Retry connecting to the most recently used profile — the pull-to-refresh action on
8894 * the server list. Gives the user a familiar gesture to retry a failed auto-connect
89− * instead of having to tap a server card. */
95+ * instead of having to tap a server card. Targets the profile with the highest
96+ * lastUsed timestamp (the most natural "retry the one I was just on"), falling back
97+ * to the first profile if none has been used yet. */
9098 fun refresh(onConnected: () -> Unit) {
9199 if (_connecting.value != null || container.reconnecting.value) return
92− val target = profiles.value.firstOrNull() ?: return
100+ val target = profiles.value.maxByOrNull { it.lastUsed }
101+ ?: profiles.value.firstOrNull() ?: return
93102 connect(target, onConnected)
94103 }
95104
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+2−1
@@ -91,6 +91,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
9191import androidx.lifecycle.viewmodel.compose.viewModel
9292import kotlinx.coroutines.coroutineScope
9393import kotlinx.coroutines.flow.collectLatest
94+import kotlinx.coroutines.flow.merge
9495import kotlinx.coroutines.delay
9596import kotlinx.coroutines.launch
9697import soy.iko.opencode.data.model.ServerProfile
@@ -205,7 +206,7 @@ fun SessionListScreen(
205206 // it via undoDelete cancels the timer and restores the row (no-op if it already
206207 // fired), so no session is silently deleted without a chance to undo.
207208 var pending: String? = null
208− vm.undoEvents.collectLatest { sessionId ->
209+ merge(vm.undoEvents, container.externalSessionUndoEvents).collectLatest { sessionId ->
209210 pending?.let { vm.undoDelete(it) }
210211 pending = sessionId
211212 coroutineScope {
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+6−3
@@ -372,7 +372,8 @@ fun SettingsScreen(
372372 val stateText = when (connectionState) {
373373 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
374374 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
375− EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
375+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
376+ EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
376377 EventStreamClient.ConnectionState.Connected -> null
377378 }
378379 stateText?.let {
@@ -380,14 +381,16 @@ fun SettingsScreen(
380381 modifier = Modifier.padding(top = 4.dp),
381382 verticalAlignment = Alignment.CenterVertically,
382383 ) {
384+ val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
385+ connectionState == EventStreamClient.ConnectionState.AuthFailed
383386 Text(
384387 it,
385388 style = MaterialTheme.typography.bodySmall,
386− color = if (connectionState == EventStreamClient.ConnectionState.Failed)
389+ color = if (isHardFailure)
387390 MaterialTheme.colorScheme.error
388391 else MaterialTheme.colorScheme.onSurfaceVariant,
389392 )
390− if (connectionState == EventStreamClient.ConnectionState.Failed) {
393+ if (isHardFailure) {
391394 TextButton(
392395 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
393396 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
app/src/main/res/values-es/strings.xml+21
@@ -38,6 +38,7 @@
3838 <string name="connecting">Conectando…</string>
3939 <string name="reconnecting">Reconectando…</string>
4040 <string name="connection_failed">Error de conexión — comprueba las credenciales</string>
41+ <string name="connection_failed_endpoint">Error de conexión — comprueba la URL y versión del servidor</string>
4142 <string name="reconnect">Reconectar</string>
4243 <string name="not_connected">Sin conexión.</string>
4344 <string name="no_servers_to_reconnect">No hay ningún servidor guardado al que reconectar.</string>
@@ -159,6 +160,7 @@
159160 <string name="tool_completed">Completado</string>
160161 <string name="tool_error">Error</string>
161162 <string name="tool_running">En ejecución</string>
163+ <string name="tool_queued">En cola</string>
162164 <string name="tool_unknown">Estado de herramienta desconocido</string>
163165 <string name="state_expanded">Expandido</string>
164166 <string name="state_collapsed">Contraído</string>
@@ -260,8 +262,16 @@
260262 <string name="notif_action_always">Siempre</string>
261263 <string name="notif_action_reject">Rechazar</string>
262264 <string name="notif_action_reply">Responder</string>
265+ <string name="notif_action_stop">Detener</string>
266+ <string name="notif_action_mark_read">Marcar leído</string>
263267 <string name="notif_reply_hint">Envía un seguimiento…</string>
264268 <string name="notif_reply_sent">Enviado</string>
269+ <string name="notif_running_progress">Paso %1$d de %2$d</string>
270+ <string name="notif_fg_timeout_text">La tarea larga podría pausarse en segundo plano</string>
271+ <string name="notif_rationale_title">¿Activar notificaciones?</string>
272+ <string name="notif_rationale_text">opencode usa notificaciones para avisarte cuando termina una ejecución, cuando un agente necesita aprobación, o cuando ocurre un error. Puedes ajustarlo luego en Ajustes.</string>
273+ <string name="notif_rationale_allow">Permitir</string>
274+ <string name="notif_rationale_skip">Omitir</string>
265275 <string name="notif_reply_failed_title">Respuesta no enviada</string>
266276 <string name="notif_reply_failed_text">No se pudo poner en cola tu seguimiento. Toca para abrir la sesión e inténtalo de nuevo.</string>
267277
@@ -543,4 +553,15 @@
543553 <item quantity="one">%1$d resultado</item>
544554 <item quantity="other">%1$d resultados</item>
545555 </plurals>
556+
557+ <!-- UI/UX batch 2: message status, crash prompt, server connection -->
558+ <string name="message_sending">Enviando…</string>
559+ <string name="message_send_failed">Error al enviar</string>
560+ <string name="message_edited">editado</string>
561+ <string name="crash_last_run_title">opencode se cerró la última vez</string>
562+ <string name="crash_last_run_text">Ocurrió un error inesperado durante la sesión anterior.</string>
563+ <string name="crash_last_run_view">Ver informe</string>
564+ <string name="crash_last_run_dismiss">Descartar</string>
565+ <string name="connecting_to">Conectando a %1$s…</string>
566+ <string name="scroll_to_top">Ir arriba</string>
546567</resources>
app/src/main/res/values-night/colors.xmlnew file+6
@@ -0,0 +1,6 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- A darker splash background at night so the cold-start flash isn't a bright rectangle. -->
4+ <color name="splash_background">#1A1B26</color>
5+ <color name="ic_launcher_background">#34548A</color>
6+</resources>
app/src/main/res/values/strings.xml+21
@@ -38,6 +38,7 @@
3838 <string name="connecting">Connecting…</string>
3939 <string name="reconnecting">Reconnecting…</string>
4040 <string name="connection_failed">Connection failed — check credentials</string>
41+ <string name="connection_failed_endpoint">Connection failed — check the server URL and version</string>
4142 <string name="reconnect">Reconnect</string>
4243 <string name="not_connected">Not connected.</string>
4344 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
@@ -159,6 +160,7 @@
159160 <string name="tool_completed">Completed</string>
160161 <string name="tool_error">Error</string>
161162 <string name="tool_running">Running</string>
163+ <string name="tool_queued">Queued</string>
162164 <string name="tool_unknown">Unknown tool state</string>
163165 <string name="state_expanded">Expanded</string>
164166 <string name="state_collapsed">Collapsed</string>
@@ -260,8 +262,16 @@
260262 <string name="notif_action_always">Always</string>
261263 <string name="notif_action_reject">Reject</string>
262264 <string name="notif_action_reply">Reply</string>
265+ <string name="notif_action_stop">Stop</string>
266+ <string name="notif_action_mark_read">Mark read</string>
263267 <string name="notif_reply_hint">Send a follow-up…</string>
264268 <string name="notif_reply_sent">Sent</string>
269+ <string name="notif_running_progress">Step %1$d of %2$d</string>
270+ <string name="notif_fg_timeout_text">Long-running task may be paused in the background</string>
271+ <string name="notif_rationale_title">Enable notifications?</string>
272+ <string name="notif_rationale_text">opencode uses notifications to alert you when a run finishes, when an agent needs approval, or when a run fails. You can adjust this later in Settings.</string>
273+ <string name="notif_rationale_allow">Allow</string>
274+ <string name="notif_rationale_skip">Skip</string>
265275 <string name="notif_reply_failed_title">Reply not sent</string>
266276 <string name="notif_reply_failed_text">Your follow-up couldn\'t be queued. Tap to open the session and try again.</string>
267277
@@ -545,4 +555,15 @@
545555 <item quantity="one">%1$d result</item>
546556 <item quantity="other">%1$d results</item>
547557 </plurals>
558+
559+ <!-- UI/UX batch 2: message status, crash prompt, server connection -->
560+ <string name="message_sending">Sending…</string>
561+ <string name="message_send_failed">Failed to send</string>
562+ <string name="message_edited">edited</string>
563+ <string name="crash_last_run_title">opencode crashed last time</string>
564+ <string name="crash_last_run_text">An unexpected error occurred during the previous session.</string>
565+ <string name="crash_last_run_view">View report</string>
566+ <string name="crash_last_run_dismiss">Dismiss</string>
567+ <string name="connecting_to">Connecting to %1$s…</string>
568+ <string name="scroll_to_top">Scroll to top</string>
548569</resources>
app/src/main/res/values/themes.xml+1
@@ -9,6 +9,7 @@
99 <!-- Shown by core-splashscreen on cold start, then swapped for the theme above. -->
1010 <style name="Theme.OpencodeAndroid.Splash" parent="Theme.SplashScreen">
1111 <item name="windowSplashScreenBackground">@color/splash_background</item>
12+ <item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
1213 <item name="postSplashScreenTheme">@style/Theme.OpencodeAndroid</item>
1314 </style>
1415</resources>