git.iko.soy/opencode-android
on master help sign in
Implement 43 UI/UX improvements across chat, sessions, server, files, settings, and notifications
commit3bab95df3ddd5e6a82a7d223366503290843e073 authoriko <mail@iko.soy> dateSat 4 Jul 2026, 18:10 (3 months ago) parentd403f66
32 files changed+1232−266
app/detekt-baseline.xml+5−6 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+50−2 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+48−8 app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+36−16 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+62 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+3 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+6 app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+35−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+148−42 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+83−6 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+216−43 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+71−21 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+21−14 app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+13−8 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+78−19 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+7−8 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+16−1 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+9−5 app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+4−4 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+25−4 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+17−1 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+27−5 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+15−5 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+54−30 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+20 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+25−11 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+27−4 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+18 app/src/main/res/values-es/strings.xml+18 app/src/main/res/values/strings.xml+18 app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+40−2
app/detekt-baseline.xml+5−6
@@ -6,29 +6,28 @@
66 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
77 <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
88 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
9− <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, focusMessageId: String? = null, )</ID>
10− <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun ChatInputBar( value: String, onValueChange: (String) -&gt; Unit, running: Boolean, aborting: Boolean, enabled: Boolean, sendOnEnter: Boolean, onSend: () -&gt; Unit, onAbort: () -&gt; Unit, queuedFollowUp: String?, onQueueFollowUp: (String) -&gt; Unit, onCancelQueue: () -&gt; Unit, focusRequester: androidx.compose.ui.focus.FocusRequester, attachments: List&lt;PendingAttachment&gt;, staging: Boolean, stagingFileCount: Int, onRemoveAttachment: (String) -&gt; Unit, onPickPhoto: () -&gt; Unit, onPickFile: () -&gt; Unit, onCamera: () -&gt; Unit, onVoice: () -&gt; Unit, onPasteImage: (List&lt;Uri&gt;) -&gt; Unit, )</ID>
9+ <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, focusMessageId: String? = null, onEditProfile: ((String) -&gt; Unit)? = null, )</ID>
1110 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
1211 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString</ID>
1312 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
1413 <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
1514 <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
1615 <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
17− <ID>CyclomaticComplexMethod:FileBrowserScreen.kt$@OptIn(ExperimentalFoundationApi::class) @Composable private fun FileRow( icon: Boolean, label: String, onClick: () -&gt; Unit, status: FileStatusEntry? = null, sublabel: String? = null, size: Long? = null, mtime: Long? = null, onCopyPath: (() -&gt; Unit)? = null, modifier: Modifier = Modifier, )</ID>
1816 <ID>CyclomaticComplexMethod:FileViewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewScreen( container: AppContainer, path: String, onBack: () -&gt; Unit, initialLine: Int? = null, )</ID>
17+ <ID>CyclomaticComplexMethod:GlobalSearchScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun GlobalSearchScreen( container: AppContainer, onOpenSession: (String, String?) -&gt; Unit, onBack: () -&gt; Unit, )</ID>
1918 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
2019 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
2120 <ID>CyclomaticComplexMethod:ServerListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerListScreen( container: AppContainer, onConnected: () -&gt; Unit, onAddProfile: () -&gt; Unit, onEditProfile: (String) -&gt; Unit, onDuplicateProfile: (String) -&gt; Unit, )</ID>
22− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionListScreen( container: AppContainer, onOpenSession: (String) -&gt; Unit, onDisconnect: () -&gt; Unit, onOpenFiles: () -&gt; Unit, onOpenSettings: () -&gt; Unit, onAddServer: () -&gt; Unit, onOpenSearch: () -&gt; Unit = {}, // Incremented by a host (the two-pane empty-detail pane) to open the new-session // directory picker, so that pane shares this screen's dialog instead of bypassing it. externalNewSessionTrigger: Int = 0, selectedSessionId: String? = null, )</ID>
23− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun androidx.compose.foundation.layout.BoxScope.SessionListBody( state: SessionListState, unread: Map&lt;String, Int&gt;, mutedSessions: Set&lt;String&gt;, refreshing: Boolean, haptics: androidx.compose.ui.hapticfeedback.HapticFeedback, selectedSessionId: String?, connected: Boolean, onRefresh: () -&gt; Unit, onReconnect: () -&gt; Unit, onQueryChange: (String) -&gt; Unit, onOpenSession: (String) -&gt; Unit, onCreateSession: () -&gt; Unit, onRename: (String) -&gt; Unit, onDelete: (String) -&gt; Unit, onPin: (Session) -&gt; Unit, onArchive: (Session) -&gt; Unit, onMarkUnread: (String) -&gt; Unit, onToggleMute: (String) -&gt; Unit, onFilterByDirectory: (String) -&gt; Unit, onClearDirectoryFilter: () -&gt; Unit, onShowArchived: () -&gt; Unit, inSelection: Boolean, selectedIds: Set&lt;String&gt;, onToggleSelect: (String) -&gt; Unit, runningSessionIds: Set&lt;String&gt; = emptySet(), )</ID>
24− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun SessionCard( session: Session, preview: String?, unreadCount: Int, onClick: () -&gt; Unit, onRename: () -&gt; Unit, onDelete: () -&gt; Unit, onPin: () -&gt; Unit, onArchive: () -&gt; Unit, onMarkUnread: () -&gt; Unit, onToggleMute: () -&gt; Unit, isMuted: Boolean, modifier: Modifier = Modifier, isSelected: Boolean = false, isPinned: Boolean = false, isArchived: Boolean = false, isRunning: Boolean = false, onFilterByDirectory: ((String) -&gt; Unit)? = null, isDirectoryFiltered: Boolean = false, inSelection: Boolean = false, onToggleSelect: (() -&gt; Unit)? = null, )</ID>
21+ <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionListScreen( container: AppContainer, onOpenSession: (String) -&gt; Unit, onDisconnect: () -&gt; Unit, onOpenFiles: () -&gt; Unit, onOpenSettings: () -&gt; Unit, onAddServer: () -&gt; Unit, onOpenSearch: () -&gt; Unit = {}, onEditProfile: ((String) -&gt; Unit)? = null, // Incremented by a host (the two-pane empty-detail pane) to open the new-session // directory picker, so that pane shares this screen's dialog instead of bypassing it. externalNewSessionTrigger: Int = 0, selectedSessionId: String? = null, )</ID>
2522 <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>
2623 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
24+ <ID>LargeClass:SessionListViewModel.kt$SessionListViewModel : ViewModel</ID>
2725 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
2826 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
2927 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
3028 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
3129 <ID>TooManyFunctions:CrashLogger.kt$CrashLogger</ID>
30+ <ID>TooManyFunctions:MessageBubble.kt$soy.iko.opencode.ui.chat.MessageBubble.kt</ID>
3231 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
3332 <ID>TooManyFunctions:SessionNotifications.kt$SessionNotifications</ID>
3433 </CurrentIssues>
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17
@@ -55,6 +55,14 @@ open class EventStreamClient(
5555 private val _state = MutableStateFlow(ConnectionState.Disconnected)
5656 open val state: StateFlow<ConnectionState> = _state.asStateFlow()
5757
58+ /** Number of reconnect attempts made since the last successful [ConnectionState.Connected]
59+ * period. Incremented each time the stream loop begins a new connection attempt after a
60+ * drop, and reset to 0 on a successful connect. Surfaced so the UI can show "Reconnecting
61+ * (attempt N)…" during a sustained outage, giving the user confidence the system is
62+ * actively retrying rather than stuck. */
63+ private val _reconnectAttempts = MutableStateFlow(0)
64+ open val reconnectAttempts: StateFlow<Int> = _reconnectAttempts.asStateFlow()
65+
5866 open val events: SharedFlow<BusEvent> by lazy {
5967 stream()
6068 .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
@@ -166,7 +174,13 @@ open class EventStreamClient(
166174 // stopped the upstream while triggerReconnect() queued a signal).
167175 while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
168176 var backoffMs = initialBackoffMs
177+ // Tracks whether this is the first iteration (the initial connect) vs a reconnect,
178+ // so the attempt counter is only incremented on reconnects (not the first connect).
179+ var firstAttempt = true
169180 while (isActive) {
181+ // Increment the reconnect-attempt counter on every iteration except the initial
182+ // connect, so the UI can show "Reconnecting (attempt N)…". Reset to 0 on success.
183+ if (firstAttempt) firstAttempt = false else _reconnectAttempts.value++
170184 _state.value = ConnectionState.Connecting
171185 // Set by readSseEvents when a triggerReconnect() signal interrupted an active
172186 // read. The signal is consumed to drop the socket, so the backoff select below
@@ -241,6 +255,9 @@ open class EventStreamClient(
241255 if (!receivedAny) {
242256 receivedAny = true
243257 backoffMs = initialBackoffMs
258+ // A genuine event confirms the connection is healthy, so reset
259+ // the reconnect-attempt counter for the next outage's count.
260+ _reconnectAttempts.value = 0
244261 }
245262 send(it)
246263 }
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+50−2
@@ -35,6 +35,10 @@ object NetworkConfig {
3535 const val sseInitialBackoffMs = 500L
3636 /** Cap on the SSE reconnect backoff. */
3737 const val sseMaxBackoffMs = 10_000L
38+ /** Reconnect-attempt count at which the connection banner switches from "Reconnecting…"
39+ * to "Reconnecting (attempt N)…" — after a few retries the user benefits from seeing the
40+ * system is actively retrying rather than stuck. Below this the plain label is calmer. */
41+ const val sseReconnectAttemptLabelThreshold = 3
3842 /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
3943 const val sseIdleTimeoutMs = 90_000L
4044 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
@@ -209,8 +213,13 @@ object NetworkConfig {
209213 * run-activity changes. A run that starts and then fails/idles almost immediately
210214 * would otherwise dispatch startForegroundService() then stopService() back-to-back;
211215 * if the stop wins the race before startForeground() runs, Android raises the
212− * "did not then call startForeground()" crash. Debouncing collapses that window. */
213− const val runForegroundDebounceMs = 500L
216+ * "did not then call startForeground()" crash. Debouncing collapses that window.
217+ * Also serves as a minimum-run-duration gate: a run shorter than this (a quick abort,
218+ * a one-token reply) clears [anyRunActive] before the FGS ever posts, so the shade
219+ * stays clean of non-dismissable notifications for trivially short runs. The Doze-
220+ * resilience benefit only matters for long runs, so a few seconds of gate preserves
221+ * the core benefit. */
222+ const val runForegroundDebounceMs = 3_000L
214223
215224 // --- Markdown rendering (MarkdownText) ---
216225
@@ -249,6 +258,14 @@ object NetworkConfig {
249258 /** Minimum number of crash reports before the diagnostics list shows its search field. */
250259 const val diagnosticsSearchThreshold = 8
251260
261+ /** Initial render window for the diagnostics crash-report list — the number of rows
262+ * composed before the user scrolls to reveal more. Bounds composition work on a device
263+ * that has accumulated many crash reports. Grows by [diagnosticsListPageStep] as the
264+ * user nears the bottom. */
265+ const val diagnosticsListInitialPage = 60
266+ /** How many additional crash-report rows to reveal when the render window is exhausted. */
267+ const val diagnosticsListPageStep = 60
268+
252269 /** Minimum number of sessions in the usage report before the per-session list shows its
253270 * filter field. A short history is faster to scan than type into. */
254271 const val usageSessionSearchThreshold = 8
@@ -271,6 +288,11 @@ object NetworkConfig {
271288 /** Right pane weight in the two-pane layout (chat detail). */
272289 const val twoPaneRightWeight = 0.62f
273290
291+ /** Maximum width (dp) for the left (session-list) pane in the two-pane layout. On a wide
292+ * tablet the list pane would otherwise stretch too wide for a readable row density; capping
293+ * it keeps the list compact and gives the chat detail pane the remaining space. */
294+ const val twoPaneLeftMaxWidthDp = 460
295+
274296 /** Minimum window width (dp) at which a NavigationRail is shown alongside the
275297 * two-pane layout for top-level destination discoverability on large screens. Sits below
276298 * [twoPaneWidthThresholdDp] (M3's WindowWidthSizeClass.Medium breakpoint ~600dp) so a
@@ -321,6 +343,25 @@ object NetworkConfig {
321343 /** Lines of tool output rendered before collapsing to a head with a "show more" affordance. */
322344 const val toolOutputCollapsedLimitChars = 4000
323345
346+ /** Lines of tool output rendered before collapsing to a head with a "show more" affordance.
347+ * A line-based threshold (rather than the char-based [toolOutputCollapsedLimitChars]) keeps
348+ * the collapsed preview a predictable height on mobile — a 4000-char block of short lines
349+ * can be 60+ lines and fill the whole screen, defeating the purpose of collapsing. The
350+ * char cap still applies as a hard upper bound; this line cap kicks in first for typical
351+ * multi-line output. */
352+ const val toolOutputCollapsedLimitLines = 30
353+
354+ /** Tool input (pretty-printed JSON) shorter than this many lines is shown expanded by
355+ * default — for tools like bash/write/edit the command is the most important info and
356+ * hiding it behind a tap inverts the priority. Longer inputs stay collapsed to avoid
357+ * dominating the bubble. */
358+ const val toolInputAutoExpandLineThreshold = 10
359+
360+ /** Tool durations below this many ms are hidden from the tool-name row (sub-second runs
361+ * are instant and the ms figure is noise). Set to 1000 to hide all sub-second durations,
362+ * or 0 to always show. */
363+ const val toolDurationHideBelowMs = 1_000L
364+
324365 /** Characters of tool output included in a shared/exported transcript before truncation. */
325366 const val exportToolOutputLimitChars = 2000
326367
@@ -339,6 +380,13 @@ object NetworkConfig {
339380 /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
340381 const val streamingCaretPeriodMs = 500
341382
383+ /** Target maximum length (chars) of a single TTS chunk. Android's TextToSpeech enforces a
384+ * hard limit (getMaxSpeechInputLength, ~4000 chars) — this is a tighter cap so pause/
385+ * resume restarts at a finer granularity. Without it, a pause mid-chunk restarts the
386+ * whole chunk from its start; smaller chunks mean less replay on resume. Sentence
387+ * boundaries are still preferred, so short sentences pack up to this cap. */
388+ const val ttsChunkTargetMaxChars = 200
389+
342390 /** Auto-reject a tool-permission prompt after this many ms if the user hasn't responded,
343391 * so a forgotten prompt doesn't block the run indefinitely. The foreground service keeps
344392 * the process alive, so without a timeout a prompt that the user walked away from holds
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+48−8
@@ -459,6 +459,47 @@ open class AppContainer private constructor(
459459 }
460460 }
461461
462+ /**
463+ * Post a system notification when the SSE stream hits a non-retryable failure
464+ * ([Failed]/[AuthFailed]) while the app is backgrounded during an active run, so a
465+ * backgrounded user is signaled that their run is stranded (the in-app banner isn't
466+ * visible). The notification is cancelled when the stream reconnects. This closes the
467+ * gap where a user who kicks off a run and backgrounds the app returns hours later to
468+ * find nothing happened and no signal why — the stream parked on a 4xx mid-run and the
469+ * in-app banner was never seen.
470+ */
471+ private fun observeBackgroundedConnectionFailures() {
472+ val ctx = appContext ?: return
473+ appScope.launch {
474+ activeConnection.collectLatest { conn ->
475+ if (conn == null) return@collectLatest
476+ conn.events.state.collect { state ->
477+ when (state) {
478+ EventStreamClient.ConnectionState.Failed,
479+ EventStreamClient.ConnectionState.AuthFailed -> {
480+ // Only notify when the app is backgrounded AND a run was recently
481+ // active (or still is) — a foregrounded user sees the banner, and
482+ // a failure with no run is less actionable.
483+ if (!_isForeground.value && _anyRunActive.value) {
484+ runCatchingCancellable {
485+ SessionNotifications.postConnectionLost(
486+ ctx, conn.profile.displayLabel, conn.profile.id,
487+ )
488+ }.onFailure {
489+ Log.w("AppContainer", "connection-lost notification failed: ${safeExceptionSummary(it)}")
490+ }
491+ }
492+ }
493+ EventStreamClient.ConnectionState.Connected -> {
494+ runCatchingCancellable { SessionNotifications.cancelConnectionLost(ctx) }
495+ }
496+ else -> {}
497+ }
498+ }
499+ }
500+ }
501+ }
502+
462503 /**
463504 * Reconcile active-run tracking on an SSE *reconnect*. A run that completes during a stream
464505 * outage never delivers its `SessionIdle`, so its id would stay in [activeRuns] and pin
@@ -563,18 +604,16 @@ open class AppContainer private constructor(
563604 "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status): ${string(R.string.outbox_dropped_text)}",
564605 )
565606 outboxStore.remove(msg.id)
566− // Previously this silent drop only hit logcat, so the user got no signal
567− // that their composed reply never landed. Surface it on the ERROR channel.
568− // postError is the only public entry point that posts there; it renders a
569− // fixed title/text and interpolates its title arg, so the localized
570− // outbox_dropped_title is passed as that arg (a user-facing message rather
571− // than an opaque session id). Tap routes back to the originating profile.
607+ // Surface the dropped reply on the ERROR channel with a distinct title
608+ // ("Message not delivered") so the user understands a queued reply
609+ // couldn't be delivered — not that a run failed (which postError
610+ // implies). Tap routes back to the originating profile's session.
572611 val ctx = appContext
573612 if (ctx != null) {
574613 runCatchingCancellable {
575− SessionNotifications.postError(
614+ SessionNotifications.postOutboxDropped(
576615 ctx, msg.sessionId,
577− string(R.string.outbox_dropped_title),
616+ msg.sessionId,
578617 conn.profile.id,
579618 )
580619 }.onFailure {
@@ -1416,6 +1455,7 @@ open class AppContainer private constructor(
14161455 observeMessageActivity()
14171456 observeRunReconcileOnReconnect()
14181457 observeRunForegroundService()
1458+ observeBackgroundedConnectionFailures()
14191459 // Load the persisted muted-sessions set so isSessionMuted() reflects the choice
14201460 // immediately on cold start (before any session list composition).
14211461 appScope.launch {
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+36−16
@@ -5,6 +5,7 @@ import android.content.Context
55import android.content.Intent
66import androidx.core.app.RemoteInput
77import soy.iko.opencode.OpencodeApp
8+import soy.iko.opencode.R
89import soy.iko.opencode.data.model.PermissionResponse
910import soy.iko.opencode.data.network.NetworkConfig
1011import java.util.concurrent.atomic.AtomicBoolean
@@ -32,22 +33,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
3233 override fun onReceive(context: Context, intent: Intent) {
3334 val container = (context.applicationContext as? OpencodeApp)?.container ?: return
3435 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() }
36+ handleStopRun(context, container, intent)
5137 return
5238 }
5339 val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
@@ -145,6 +131,40 @@ class NotificationActionReceiver : BroadcastReceiver() {
145131 }
146132 }
147133
134+ /** Handle the Stop action from the foreground-service notification. Extracted from
135+ * [onReceive] to keep that function's cyclomatic complexity under detekt's threshold. */
136+ private fun handleStopRun(context: Context, container: soy.iko.opencode.di.AppContainer, intent: Intent) {
137+ // The session id may be absent if multiple runs were active (generic notification);
138+ // in that case we can't target a specific run — just let it ride.
139+ val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
140+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
141+ val pending = goAsync()
142+ val finished = AtomicBoolean(false)
143+ var watchdogThread: Thread? = null
144+ val finishOnce = {
145+ if (finished.compareAndSet(false, true)) {
146+ watchdogThread?.interrupt()
147+ pending.finish()
148+ }
149+ }
150+ watchdogThread = watchdog(finishOnce)
151+ container.abortRunFromNotification(sessionId, profileId) { success ->
152+ // On a failed abort (transient network error), the run continues but the FGS
153+ // notification will disappear once anyRunActive clears — leaving the user with
154+ // no signal that their Stop tap didn't take. Post a quick "couldn't stop"
155+ // notification so they know to open the app. On success, the FGS disappearing
156+ // is sufficient feedback.
157+ if (!success) {
158+ SessionNotifications.postError(
159+ context, sessionId,
160+ context.getString(R.string.couldnt_stop_run),
161+ profileId,
162+ )
163+ }
164+ finishOnce()
165+ }
166+ }
167+
148168 /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
149169 * the network call hasn't resolved, so the system can't ANR the receiver. The
150170 * underlying work continues on the app scope. Returns the watchdog thread so the
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+62
@@ -335,6 +335,68 @@ object SessionNotifications {
335335 .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
336336 }
337337
338+ /** A queued outbox message was permanently undeliverable (e.g. the target session was
339+ * deleted server-side → 404). Distinct from [postError] (which is titled "Run failed"):
340+ * no run failed — a queued reply couldn't be delivered. Uses a distinct title/body so the
341+ * user understands the failure mode and recovery action (open the session and re-send). */
342+ @SuppressLint("MissingPermission")
343+ fun postOutboxDropped(context: Context, sessionId: String, sessionTitle: String, profileId: String? = null) {
344+ if (!canPost(context)) return
345+ val notifId = notifId(NS_ERROR, sessionId)
346+ val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
347+ .setSmallIcon(R.drawable.ic_stat_notify)
348+ .setContentTitle(context.getString(R.string.message_not_delivered))
349+ .setContentText(context.getString(R.string.message_not_delivered_body, sessionTitle))
350+ .setAutoCancel(true)
351+ .setOnlyAlertOnce(true)
352+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
353+ .setCategory(NotificationCompat.CATEGORY_ERROR)
354+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
355+ .setGroup(GROUP_ERROR)
356+ .build()
357+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
358+ .onFailure { Log.w(TAG, "Failed to post outbox-dropped notification", it) }
359+ runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
360+ .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
361+ }
362+
363+ /** The SSE stream hit a non-retryable failure (Failed/AuthFailed) while the app was
364+ * backgrounded during an active run, so the in-app banner isn't visible and the run is
365+ * effectively stranded. Posts a low-priority notification so a backgrounded user is
366+ * signaled that their run is stalled and can tap to reconnect. Distinct from [postError]
367+ * (a run error) — this is a connection loss, not a run failure. */
368+ @SuppressLint("MissingPermission")
369+ fun postConnectionLost(context: Context, profileLabel: String, profileId: String?) {
370+ if (!canPost(context)) return
371+ val notifId = NOTIF_ID_PREFIX + 7000
372+ // Tap reopens the app at the server list so the user can reconnect / fix credentials.
373+ val openIntent = Intent(context, MainActivity::class.java).apply {
374+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
375+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
376+ }
377+ val openPending = PendingIntent.getActivity(
378+ context, notifId, openIntent,
379+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
380+ )
381+ val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
382+ .setSmallIcon(R.drawable.ic_stat_notify)
383+ .setContentTitle(context.getString(R.string.connection_lost, profileLabel))
384+ .setContentText(context.getString(R.string.connection_lost_tap))
385+ .setAutoCancel(true)
386+ .setOnlyAlertOnce(true)
387+ .setContentIntent(openPending)
388+ .setCategory(NotificationCompat.CATEGORY_STATUS)
389+ .setPriority(NotificationCompat.PRIORITY_LOW)
390+ .build()
391+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
392+ .onFailure { Log.w(TAG, "Failed to post connection-lost notification", it) }
393+ }
394+
395+ /** Cancel a previously-posted [postConnectionLost] notification (e.g. on reconnect). */
396+ fun cancelConnectionLost(context: Context) {
397+ NotificationManagerCompat.from(context).cancel(NOTIF_ID_PREFIX + 7000)
398+ }
399+
338400 /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
339401 private fun mutableFlag(): Int =
340402 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+3
@@ -400,6 +400,7 @@ fun OpencodeApp(container: AppContainer) {
400400 navController.popBackStack(Routes.SERVERS, inclusive = false)
401401 },
402402 onAddServer = { navController.navigate(Routes.serverEdit()) },
403+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
403404 )
404405 } else {
405406 SessionListScreen(
@@ -422,6 +423,7 @@ fun OpencodeApp(container: AppContainer) {
422423 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
423424 onOpenSearch = { navController.navigate(Routes.SEARCH) },
424425 onAddServer = { navController.navigate(Routes.serverEdit()) },
426+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
425427 )
426428 }
427429 }
@@ -447,6 +449,7 @@ fun OpencodeApp(container: AppContainer) {
447449 launchSingleTop = true
448450 }
449451 },
452+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
450453 )
451454 }
452455
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+6
@@ -51,6 +51,7 @@ import androidx.compose.ui.semantics.selectableGroup
5151import androidx.compose.ui.semantics.selected
5252import androidx.compose.ui.semantics.semantics
5353import androidx.compose.ui.text.input.ImeAction
54+import androidx.compose.ui.text.style.TextOverflow
5455import androidx.compose.ui.unit.dp
5556import androidx.compose.foundation.layout.Box
5657import kotlinx.coroutines.launch
@@ -300,6 +301,11 @@ fun AgentPickerSheet(
300301 agent.displayDescription,
301302 style = MaterialTheme.typography.bodySmall,
302303 color = MaterialTheme.colorScheme.onSurfaceVariant,
304+ // Cap at 2 lines (matching the command picker) so a verbose
305+ // description doesn't make the row arbitrarily tall and push the
306+ // radio affordance out of alignment across the list.
307+ maxLines = 2,
308+ overflow = TextOverflow.Ellipsis,
303309 )
304310 }
305311 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+35−1
@@ -10,11 +10,13 @@ import androidx.compose.foundation.layout.padding
1010import androidx.compose.foundation.layout.size
1111import androidx.compose.foundation.layout.widthIn
1212import androidx.compose.foundation.clickable
13+import androidx.compose.foundation.combinedClickable
1314import androidx.compose.foundation.horizontalScroll
1415import androidx.compose.foundation.rememberScrollState
1516import androidx.compose.material.icons.Icons
1617import androidx.compose.material.icons.filled.Close
1718import androidx.compose.material.icons.filled.Description
19+import androidx.compose.material3.AlertDialog
1820import androidx.compose.material3.CircularProgressIndicator
1921import androidx.compose.material3.Icon
2022import androidx.compose.material3.IconButton
@@ -153,7 +155,9 @@ private fun StagingChip(stagingFileCount: Int) {
153155@Composable
154156private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
155157 var showPreview by remember { mutableStateOf(false) }
158+ var showNameDialog by remember { mutableStateOf(false) }
156159 val previewLabel = stringResource(R.string.preview_attachment)
160+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
157161 Surface(
158162 shape = MaterialTheme.shapes.small,
159163 color = MaterialTheme.colorScheme.surfaceVariant,
@@ -172,10 +176,22 @@ private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit)
172176 .clickable(role = Role.Image) { showPreview = true },
173177 )
174178 } else {
179+ // Long-press the file icon to see the full filename (it's truncated to 120dp
180+ // in the chip, so a long name can't be read in full otherwise). Mirrors the
181+ // image chip's tap-to-preview pattern for non-image files.
175182 Icon(
176183 Icons.Filled.Description,
177184 contentDescription = null,
178− modifier = Modifier.size(24.dp),
185+ modifier = Modifier
186+ .size(24.dp)
187+ .combinedClickable(
188+ role = Role.Image,
189+ onLongClick = {
190+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
191+ showNameDialog = true
192+ },
193+ onClick = {},
194+ ),
179195 tint = MaterialTheme.colorScheme.onSurfaceVariant,
180196 )
181197 }
@@ -221,4 +237,22 @@ private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit)
221237 }
222238 }
223239 }
240+ if (showNameDialog) {
241+ AlertDialog(
242+ onDismissRequest = { showNameDialog = false },
243+ title = { Text(stringResource(R.string.attachment_name)) },
244+ text = {
245+ Text(
246+ attachment.name,
247+ style = MaterialTheme.typography.bodyMedium,
248+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
249+ )
250+ },
251+ confirmButton = {
252+ androidx.compose.material3.TextButton(onClick = { showNameDialog = false }) {
253+ Text(stringResource(R.string.close))
254+ }
255+ },
256+ )
257+ }
224258}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+148−42
@@ -203,10 +203,12 @@ fun ChatScreen(
203203 onOpenFile: ((String) -> Unit)? = null,
204204 onOpenSession: ((String) -> Unit)? = null,
205205 focusMessageId: String? = null,
206+ onEditProfile: ((String) -> Unit)? = null,
206207) {
207208 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
208209 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
209210 val running by vm.running.collectAsStateWithLifecycle()
211+ val streamInterrupted by vm.streamInterrupted.collectAsStateWithLifecycle()
210212 val aborting by vm.aborting.collectAsStateWithLifecycle()
211213 val loading by vm.loading.collectAsStateWithLifecycle()
212214 val loadError by vm.loadError.collectAsStateWithLifecycle()
@@ -217,6 +219,7 @@ fun ChatScreen(
217219 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
218220 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
219221 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
222+ val reconnectAttempts by vm.reconnectAttempts.collectAsStateWithLifecycle()
220223 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
221224 val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
222225 val agents by vm.agents.collectAsStateWithLifecycle()
@@ -296,7 +299,6 @@ fun ChatScreen(
296299 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
297300 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
298301 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
299− var showStopConfirm by rememberSaveable { mutableStateOf(false) }
300302 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
301303 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
302304 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
@@ -363,6 +365,20 @@ fun ChatScreen(
363365 }
364366 }
365367
368+ // Stop-undo: abort() emits the last-sent prompt here so this collector can show a
369+ // "Stopped — Undo" snackbar. Tapping Undo re-sends the prompt, recovering an accidental
370+ // Stop without a confirmation dialog (Stop now happens on first tap).
371+ val undoStopLabel = stringResource(R.string.undo)
372+ val stoppedLabel = stringResource(R.string.run_stopped_undo)
373+ LaunchedEffect(Unit) {
374+ vm.stopUndoEvents.collect { prompt ->
375+ if (prompt != null) {
376+ val result = snackbar.showSnackbar(message = stoppedLabel, actionLabel = undoStopLabel)
377+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.resendLastPrompt()
378+ }
379+ }
380+ }
381+
366382 // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
367383 // window on the container; show the snackbar over the conversation the user is viewing so
368384 // they can cancel without being navigated to the session list first. If the window expires
@@ -547,8 +563,8 @@ fun ChatScreen(
547563 ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
548564 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
549565 ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
550− ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
551− showStopConfirm = true; true
566+ ev.key == Key.Escape && running && !showExitConfirm -> {
567+ vm.abort(); true
552568 }
553569 else -> false
554570 }
@@ -893,7 +909,7 @@ fun ChatScreen(
893909 enabled = activeConnection != null,
894910 sendOnEnter = sendOnEnter,
895911 onSend = ::doSend,
896− onAbort = { showStopConfirm = true },
912+ onAbort = { vm.abort() },
897913 queuedFollowUp = queuedFollowUp,
898914 onQueueFollowUp = vm::queueFollowUp,
899915 onCancelQueue = { vm.queueFollowUp("") },
@@ -1155,7 +1171,21 @@ fun ChatScreen(
11551171 // content padding accounts for the bottomBar's raised height, so the message
11561172 // list is already above the keyboard. Adding imePadding here would double-apply
11571173 // the IME inset and push messages too far up.
1158− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
1174+ //
1175+ // BoxWithConstraints so the FABs and search bar can align to the capped (800dp)
1176+ // message list on wide screens instead of floating at the screen edges — on a
1177+ // tablet the list is a centered column, so edge-aligned FABs are visually detached.
1178+ // The horizontal margin below is applied to the FAB/search containers so they
1179+ // track the list's left/right edges.
1180+ androidx.compose.foundation.layout.BoxWithConstraints(
1181+ modifier = Modifier.fillMaxSize().padding(padding),
1182+ ) {
1183+ val screenMaxWidth = maxWidth
1184+ val listMaxWidth = NetworkConfig.chatContentMaxWidthDp.dp
1185+ // Horizontal margin between the capped list's edge and the screen edge (0 on
1186+ // screens narrower than the cap). Applied as start/end padding so the FABs and
1187+ // search bar sit at the list's edges, not the screen's.
1188+ val sideMargin = ((screenMaxWidth - listMaxWidth) / 2).coerceAtLeast(0.dp)
11591189 if (activeConnection == null) {
11601190 Column(
11611191 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -1187,6 +1217,10 @@ fun ChatScreen(
11871217 // fires while the banner is composed (visible), so gate the inset on
11881218 // bannerVisible and fall back to 44dp until the first measurement arrives.
11891219 var bannerHeightPx by remember { mutableIntStateOf(0) }
1220+ // Track the search bar height so the list's top padding can grow when search
1221+ // is active — otherwise a scrolled-to match lands at index 0, hidden behind
1222+ // the floating search bar (the "scroll-under-the-search-bar" bug).
1223+ var searchbarHeightPx by remember { mutableIntStateOf(0) }
11901224 val density = LocalDensity.current
11911225 val topPad = if (bannerVisible) {
11921226 if (bannerHeightPx > 0) {
@@ -1196,17 +1230,29 @@ fun ChatScreen(
11961230 }
11971231 } else {
11981232 16.dp
1233+ } + if (searchActive && searchbarHeightPx > 0) {
1234+ with(density) { searchbarHeightPx.toDp() } + 4.dp
1235+ } else {
1236+ 0.dp
11991237 }
12001238 ConnectionBanner(
12011239 state = connectionState,
12021240 modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
12031241 isOnline = isOnline,
1204− // On a hard failure, retry by forcing an SSE reconnect (which
1242+ // On a hard endpoint failure, retry by forcing an SSE reconnect (which
12051243 // re-seeds from REST). refreshMessages is the right recovery path
12061244 // when the connection is present but the stream died; reconnect()
12071245 // is the path when the whole connection is gone (handled by the
12081246 // separate "Not connected" state below).
12091247 onRetry = { vm.refreshMessages() },
1248+ // On an auth failure (401/403), retrying with the same bad credentials
1249+ // is futile — offer a one-tap path to edit the active profile's
1250+ // credentials instead of making the user hunt for the server list.
1251+ onEditCredentials = {
1252+ val profileId = activeConnection?.profile?.id
1253+ if (profileId != null) onEditProfile?.invoke(profileId)
1254+ },
1255+ reconnectAttempts = reconnectAttempts,
12101256 )
12111257 // Persistent inline error banner for a mid-conversation load failure.
12121258 // Unlike the one-shot snackbar (which fires once per streak then goes
@@ -1286,6 +1332,27 @@ fun ChatScreen(
12861332 Button(onClick = { vm.refreshMessages() }) {
12871333 Text(stringResource(R.string.retry))
12881334 }
1335+ // When the VM's retryWhen loop is actively retrying (loading is true
1336+ // on top of the error state), surface a small "retrying…" indicator so
1337+ // the user knows the system is working — without it, the static error
1338+ // + Retry button reads as "broken, tap to fix", and the user may not
1339+ // realize an automatic retry is already in progress.
1340+ if (loading) {
1341+ Spacer(Modifier.size(12.dp))
1342+ val retryingLabel = stringResource(R.string.retrying_ellipsis)
1343+ Row(verticalAlignment = Alignment.CenterVertically) {
1344+ CircularProgressIndicator(
1345+ Modifier.size(14.dp),
1346+ strokeWidth = 2.dp,
1347+ )
1348+ Text(
1349+ retryingLabel,
1350+ style = MaterialTheme.typography.labelSmall,
1351+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1352+ modifier = Modifier.padding(start = 6.dp),
1353+ )
1354+ }
1355+ }
12891356 }
12901357 } else if (messages.isEmpty() && running) {
12911358 // A run just started but no parts have arrived yet — the empty list with
@@ -1391,8 +1458,10 @@ fun ChatScreen(
13911458 }
13921459 // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
13931460 // snapping back (the message isn't dismissed — the quote fills the composer).
1394− // Only enabled when there's text to quote, so non-text messages don't
1395− // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
1461+ // Enabled for non-text messages too (image/tool-only), but the confirm
1462+ // callback gives a haptic without triggering reply — so the user gets a
1463+ // perceptible "can't quote this" signal instead of a silently dead gesture.
1464+ val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
13961465 val swipeState = rememberSwipeToDismissBoxState(
13971466 confirmValueChange = { value ->
13981467 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
@@ -1400,10 +1469,18 @@ fun ChatScreen(
14001469 } else {
14011470 SwipeToDismissBoxValue.StartToEnd
14021471 }
1403− if (value == replyValue && quoteText != null) {
1404− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1405− vm.quoteReply(quoteText)
1406− runCatching { inputFocusRequester.requestFocus() }
1472+ if (value == replyValue) {
1473+ if (quoteText != null) {
1474+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1475+ vm.quoteReply(quoteText)
1476+ runCatching { inputFocusRequester.requestFocus() }
1477+ } else {
1478+ // Non-text message: give a longer haptic so the user
1479+ // feels the gesture was received but can't quote it,
1480+ // and surface a brief toast so the reason is clear.
1481+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1482+ scope.launch { snackbar.showSnackbar(cantQuoteMsg) }
1483+ }
14071484 }
14081485 false
14091486 },
@@ -1415,8 +1492,8 @@ fun ChatScreen(
14151492 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
14161493 SwipeToDismissBox(
14171494 state = swipeState,
1418− enableDismissFromEndToStart = !replyFromStartToEnd && quoteText != null,
1419− enableDismissFromStartToEnd = replyFromStartToEnd && quoteText != null,
1495+ enableDismissFromEndToStart = !replyFromStartToEnd,
1496+ enableDismissFromStartToEnd = replyFromStartToEnd,
14201497 backgroundContent = {
14211498 Box(
14221499 Modifier.fillMaxSize().padding(horizontal = 16.dp),
@@ -1528,6 +1605,11 @@ fun ChatScreen(
15281605 if (running) {
15291606 item(key = "__typing") {
15301607 val workingText = stringResource(R.string.working)
1608+ // When the SSE stream dropped mid-run, show a "reconnecting" label
1609+ // instead of the plain "working" text so the user understands the
1610+ // run is still active but the stream is reconnecting (not finished).
1611+ val reconnectingText = stringResource(R.string.reconnecting)
1612+ val interruptedLabel = if (streamInterrupted) reconnectingText else workingText
15311613 // Elapsed-since-run chip driven by the VM's runStartMs so the timer
15321614 // survives LazyColumn recycling — a local remember reset to 0:00 when
15331615 // the row was disposed and scrolled back into view. The VM stamps the
@@ -1574,11 +1656,18 @@ fun ChatScreen(
15741656 Row(
15751657 verticalAlignment = Alignment.CenterVertically,
15761658 modifier = Modifier.semantics(mergeDescendants = true) {
1577− contentDescription = "$workingText $elapsedText"
1659+ contentDescription = "$interruptedLabel $elapsedText"
15781660 },
15791661 ) {
1580− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1581− Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1662+ // Dim the spinner when interrupted so the row reads as
1663+ // "paused/reconnecting" rather than actively progressing.
1664+ CircularProgressIndicator(
1665+ Modifier.size(16.dp),
1666+ strokeWidth = 2.dp,
1667+ color = if (streamInterrupted) MaterialTheme.colorScheme.onSurfaceVariant
1668+ else MaterialTheme.colorScheme.primary,
1669+ )
1670+ Text(interruptedLabel, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
15821671 Spacer(Modifier.size(8.dp))
15831672 Icon(
15841673 Icons.Filled.Schedule,
@@ -1600,7 +1689,7 @@ fun ChatScreen(
16001689 Spacer(Modifier.weight(1f))
16011690 val stopLabel = stringResource(R.string.stop)
16021691 IconButton(
1603− onClick = { showStopConfirm = true },
1692+ onClick = { vm.abort() },
16041693 modifier = Modifier.semantics { contentDescription = stopLabel },
16051694 ) {
16061695 Icon(Icons.Filled.Stop, contentDescription = null)
@@ -1618,7 +1707,9 @@ fun ChatScreen(
16181707 visible = !isPinnedToBottom && listItems.isNotEmpty(),
16191708 enter = fabMotion.enter,
16201709 exit = fabMotion.exit,
1621− modifier = Modifier.align(Alignment.BottomEnd),
1710+ // Inset by sideMargin on wide screens so the FAB tracks the capped list's
1711+ // edge instead of floating at the screen edge (visually detached on tablets).
1712+ modifier = Modifier.align(Alignment.BottomEnd).padding(end = sideMargin),
16221713 ) {
16231714 // Badge the FAB when new content arrived while the user was scrolled up, so
16241715 // they know there's something new to jump to (cleared once back at bottom).
@@ -1669,7 +1760,7 @@ fun ChatScreen(
16691760 visible = scrolledDown && listItems.size > 4,
16701761 enter = fabMotion.enter,
16711762 exit = fabMotion.exit,
1672− modifier = Modifier.align(Alignment.BottomStart),
1763+ modifier = Modifier.align(Alignment.BottomStart).padding(start = sideMargin),
16731764 ) {
16741765 SmallFloatingActionButton(
16751766 onClick = {
@@ -1690,13 +1781,16 @@ fun ChatScreen(
16901781 visible = searchActive,
16911782 enter = searchMotion.enter,
16921783 exit = searchMotion.exit,
1693− modifier = Modifier.align(Alignment.TopCenter),
1784+ // Inset by sideMargin on wide screens so the search bar spans the capped
1785+ // list's width, not the full screen width.
1786+ modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = sideMargin),
16941787 ) {
16951788 Surface(
16961789 tonalElevation = 3.dp,
16971790 modifier = Modifier
16981791 .padding(horizontal = 12.dp, vertical = 4.dp)
1699− .fillMaxWidth(),
1792+ .fillMaxWidth()
1793+ .onSizeChanged { searchbarHeightPx = it.height },
17001794 ) {
17011795 // Focus the search field when the bar appears (from the overflow menu's
17021796 // "Find in conversation" or Ctrl+F), so the user can start typing
@@ -1728,7 +1822,12 @@ fun ChatScreen(
17281822 } else "",
17291823 style = MaterialTheme.typography.labelSmall,
17301824 color = MaterialTheme.colorScheme.onSurfaceVariant,
1731− modifier = Modifier.padding(horizontal = 4.dp),
1825+ modifier = Modifier
1826+ .padding(horizontal = 4.dp)
1827+ // Live region so a TalkBack user hears "3 of 12" / "No
1828+ // matches" announce as they step through results, not just
1829+ // the sighted user seeing the counter update.
1830+ .semantics { liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite },
17321831 )
17331832 // Step through matches (Previous / Next), disabled when there are
17341833 // fewer than two to navigate. Wrap around at the ends so repeated
@@ -1798,24 +1897,6 @@ fun ChatScreen(
17981897 )
17991898 }
18001899
1801− if (showStopConfirm) {
1802− AlertDialog(
1803− onDismissRequest = { showStopConfirm = false },
1804− title = { Text(stringResource(R.string.stop_run_title)) },
1805− text = { Text(stringResource(R.string.stop_run_text)) },
1806− confirmButton = {
1807− TextButton(onClick = {
1808− showStopConfirm = false
1809− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1810− vm.abort()
1811− }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
1812− },
1813− dismissButton = {
1814− TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
1815− },
1816− )
1817− }
1818−
18191900 if (showSummarizeConfirm) {
18201901 AlertDialog(
18211902 onDismissRequest = { showSummarizeConfirm = false },
@@ -1899,6 +1980,10 @@ fun ChatScreen(
18991980 container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
19001981 }
19011982 }
1983+ // Resolve snackbar strings in the composable scope; the onSetPreferredModel lambda
1984+ // below isn't @Composable, so stringResource can't be called inside it.
1985+ val defaultModelClearedMsg = stringResource(R.string.default_model_cleared)
1986+ val defaultModelSetFmt = stringResource(R.string.default_model_set, "%s")
19021987 ModelPickerSheet(
19031988 options = models,
19041989 selected = selectedModel,
@@ -1912,7 +1997,18 @@ fun ChatScreen(
19121997 onDismiss = { showModelPicker = false },
19131998 preferredModelId = preferredModelId,
19141999 onSetPreferredModel = { id ->
1915− scope.launch { runCatchingCancellable { container.settingsStore.setPreferredModelId(id) } }
2000+ scope.launch {
2001+ runCatchingCancellable { container.settingsStore.setPreferredModelId(id) }
2002+ // Confirm the change with a snackbar so the user sees it took effect —
2003+ // the toggle's label flip can be missed, especially when clearing.
2004+ val msg = if (id.isEmpty()) {
2005+ defaultModelClearedMsg
2006+ } else {
2007+ val label = models.firstOrNull { it.modelID == id }?.modelLabel ?: id
2008+ defaultModelSetFmt.format(label)
2009+ }
2010+ snackbar.showSnackbar(msg)
2011+ }
19162012 },
19172013 recent = recentOptions,
19182014 )
@@ -2294,6 +2390,16 @@ private fun ChatInputBar(
22942390 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
22952391 else MaterialTheme.colorScheme.onSurfaceVariant,
22962392 )
2393+ } else if (value.lines().size > 6) {
2394+ // The inline field caps at 6 lines; once the draft crosses that,
2395+ // hint that a bigger full-screen editor exists (the Expand icon
2396+ // above is easy to miss among the icon row). A tappable label
2397+ // would be ideal, but a plain hint is enough to draw attention.
2398+ Text(
2399+ stringResource(R.string.open_full_editor_hint),
2400+ style = MaterialTheme.typography.labelSmall,
2401+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2402+ )
22972403 }
22982404 },
22992405 keyboardOptions = KeyboardOptions(
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+83−6
@@ -342,6 +342,17 @@ class ChatViewModel(
342342 private val _running = MutableStateFlow(false)
343343 val running: StateFlow<Boolean> = _running.asStateFlow()
344344
345+ /**
346+ * True when the SSE stream dropped mid-run (a transient [Disconnected] while a run is
347+ * active). The UI uses this to switch the working indicator to a "stream interrupted /
348+ * reconnecting" visual instead of clearing [_running] outright — the run is still active
349+ * on the server, so showing it as finished would mislead the user into typing a follow-up
350+ * or navigating away. Cleared when the stream reconnects ([Connected]) or when a hard
351+ * failure ([Failed]/[AuthFailed]) genuinely clears [_running]. See the state watcher in
352+ * [init]. */
353+ private val _streamInterrupted = MutableStateFlow(false)
354+ val streamInterrupted: StateFlow<Boolean> = _streamInterrupted.asStateFlow()
355+
345356 /**
346357 * Wall-clock millis at which the current run started (0L when no run is active). Drives
347358 * the typing row's elapsed timer so it survives LazyColumn recycling — a local `remember`
@@ -388,6 +399,17 @@ class ChatViewModel(
388399 private val _queuedFollowUp = MutableStateFlow<String?>(null)
389400 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
390401
402+ /** The text of the most recently-sent user prompt (captured in [send]), so an undo-able
403+ * Stop can re-send it. Only text-bearing prompts are tracked — an image-only prompt has
404+ * nothing to re-send text-wise. Cleared when a new run starts with different text. */
405+ private var lastSentPrompt: String? = null
406+
407+ /** One-shot event signals for an undoable Stop. The UI collects this and shows an
408+ * "Stopped — Undo" snackbar; tapping Undo calls [resendLastPrompt]. A Channel so an
409+ * emit before the UI subscribes is still delivered. */
410+ private val _stopUndoEvents = Channel<String?>(Channel.CONFLATED)
411+ val stopUndoEvents: Flow<String?> = _stopUndoEvents.receiveAsFlow()
412+
391413 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
392414 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
393415 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
@@ -429,6 +451,17 @@ class ChatViewModel(
429451 initialValue = EventStreamClient.ConnectionState.Disconnected,
430452 )
431453
454+ /** SSE reconnect-attempt count for the active connection, surfaced so the chat's
455+ * connection banner can show "Reconnecting (attempt N)…" during a sustained outage. */
456+ val reconnectAttempts: StateFlow<Int> =
457+ container.activeConnection
458+ .flatMapLatest { it?.events?.reconnectAttempts ?: flowOf(0) }
459+ .stateIn(
460+ scope = viewModelScope,
461+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
462+ initialValue = 0,
463+ )
464+
432465 private val _pendingPermission = MutableStateFlow<Permission?>(null)
433466 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
434467
@@ -763,6 +796,7 @@ class ChatViewModel(
763796 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
764797 // returns with no connection) until a later reconnect happens to clear it.
765798 _running.value = false
799+ _streamInterrupted.value = false
766800 _aborting.value = false
767801 clearPermissions()
768802 sessionAllowed.clear()
@@ -771,6 +805,7 @@ class ChatViewModel(
771805 return@collectLatest
772806 }
773807 _running.value = false
808+ _streamInterrupted.value = false
774809 // Allow the re-light below to recover a still-running run after this (re)connect;
775810 // a run that actually finished during the outage emits no live parts, so this
776811 // won't spuriously re-light.
@@ -942,6 +977,11 @@ class ChatViewModel(
942977 )
943978 // If the SSE stream drops mid-run, the run indicator would spin forever;
944979 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
980+ // On a *transient* drop (Disconnected) we don't clear _running — the run is still
981+ // active on the server, and showing it as finished would mislead the user. Instead
982+ // we set _streamInterrupted so the working row switches to a "reconnecting" visual.
983+ // On a *hard* failure (Failed/AuthFailed) the run genuinely can't continue without
984+ // user action, so _running is cleared. _streamInterrupted is cleared on Connected.
945985 viewModelScope.launch {
946986 container.activeConnection.collectLatest { conn ->
947987 if (conn == null) return@collectLatest
@@ -954,11 +994,28 @@ class ChatViewModel(
954994 while (currentCoroutineContext().isActive) {
955995 try {
956996 conn.events.state.collect { state ->
957− val isDown = state == EventStreamClient.ConnectionState.Disconnected ||
958− state == EventStreamClient.ConnectionState.Failed ||
959− state == EventStreamClient.ConnectionState.AuthFailed
960− if (isDown && _running.value) {
961− _running.value = false
997+ when (state) {
998+ EventStreamClient.ConnectionState.Connected -> {
999+ _streamInterrupted.value = false
1000+ }
1001+ EventStreamClient.ConnectionState.Disconnected -> {
1002+ // Transient: the stream dropped but the system is
1003+ // reconnecting. Flag interrupted so the working row
1004+ // shows a "reconnecting" state instead of vanishing.
1005+ if (_running.value) _streamInterrupted.value = true
1006+ }
1007+ EventStreamClient.ConnectionState.Failed,
1008+ EventStreamClient.ConnectionState.AuthFailed -> {
1009+ // Hard failure: the run can't continue without user
1010+ // action (fix credentials / server). Clear running and
1011+ // the interrupted flag so the UI doesn't look stuck.
1012+ _streamInterrupted.value = false
1013+ if (_running.value) _running.value = false
1014+ }
1015+ EventStreamClient.ConnectionState.Connecting -> {
1016+ // Initial connect or reconnect attempt; leave running
1017+ // as-is — interrupted (if set) stays until Connected.
1018+ }
9621019 }
9631020 }
9641021 } catch (e: CancellationException) {
@@ -1045,6 +1102,9 @@ class ChatViewModel(
10451102 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
10461103 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
10471104 }
1105+ // Remember the sent text so an undoable Stop can re-send it. Only text-bearing prompts
1106+ // are tracked (an image-only prompt has nothing to re-send).
1107+ if (trimmed.isNotEmpty()) lastSentPrompt = trimmed
10481108 viewModelScope.launch {
10491109 runCatchingCancellable {
10501110 conn.repository.sendPrompt(
@@ -1304,7 +1364,14 @@ class ChatViewModel(
13041364 // before the request even reaches the server) and the run keeps burning tokens
13051365 // server-side despite the user explicitly stopping it.
13061366 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1307− .onSuccess { _running.value = false }
1367+ .onSuccess {
1368+ _running.value = false
1369+ // Surface an undo opportunity so an accidental Stop is recoverable.
1370+ // The UI shows a "Stopped — Undo" snackbar; tapping Undo re-sends the
1371+ // last prompt. Only when there's a tracked prompt to undo back to.
1372+ val prompt = lastSentPrompt
1373+ if (prompt != null) _stopUndoEvents.trySend(prompt)
1374+ }
13081375 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
13091376 } finally {
13101377 _aborting.value = false
@@ -1312,6 +1379,16 @@ class ChatViewModel(
13121379 }
13131380 }
13141381
1382+ /** Re-send the most recently-sent user prompt, used by the Stop-undo snackbar so an
1383+ * accidental Stop can be recovered. No-op if no prompt is tracked (e.g. the run was
1384+ * started by a command, not a text prompt). Clears the tracked prompt so a second Undo
1385+ * tap doesn't double-send. */
1386+ fun resendLastPrompt() {
1387+ val prompt = lastSentPrompt ?: return
1388+ lastSentPrompt = null
1389+ send(prompt, includeAttachments = false)
1390+ }
1391+
13151392 fun addAttachment(attachment: PendingAttachment) {
13161393 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
13171394 // count caps are checked upstream). The base64 data URLs are held in memory and
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+216−43
@@ -273,17 +273,32 @@ private fun UserMessageLongPressMenu(
273273 )
274274}
275275
276−/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session, view source).
277− * Shown when at least one action is available and there's text to act on. Keeps the
278− * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
276+/** Overflow menu of per-message actions, surfaced as a visible icon button in the footer so
277+ * the full action set is reachable in one tap from a visible affordance — not only via the
278+ * long-press gesture (which is consumed by the SelectionContainer on selectable text and so
279+ * only fires from the thin footer/inter-part spacing). Mirrors the [MessageLongPressMenu]
280+ * action set so both entry points expose the same actions. Items with null callbacks (or no
281+ * text to act on) are omitted. */
279282@Composable
280283private fun MessageOverflow(
281284 text: String,
282285 onQuote: ((String) -> Unit)?,
283286 onBranch: ((String) -> Unit)?,
284287 onViewSource: (() -> Unit)? = null,
288+ onCopy: (() -> Unit)? = null,
289+ onEdit: (() -> Unit)? = null,
290+ onRegenerate: (() -> Unit)? = null,
291+ onRevert: (() -> Unit)? = null,
292+ onShare: (() -> Unit)? = null,
293+ onSpeak: (() -> Unit)? = null,
294+ speakLabel: String? = null,
285295) {
286− if (onQuote == null && onBranch == null && onViewSource == null) return
296+ // No actions to show at all → render nothing. The combined check keeps the guard readable
297+ // (detekt's ComplexCondition threshold flags a single long && chain).
298+ val hasAnyAction = listOf(
299+ onQuote, onBranch, onViewSource, onCopy, onEdit, onRegenerate, onRevert, onShare,
300+ ).any { it != null } || (onSpeak != null && speakLabel != null)
301+ if (!hasAnyAction) return
287302 var expanded by remember { mutableStateOf(false) }
288303 val moreLabel = stringResource(R.string.message_actions)
289304 val haptics = LocalHapticFeedback.current
@@ -297,43 +312,145 @@ private fun MessageOverflow(
297312 )
298313 }
299314 DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
300− onQuote?.let { quote ->
301− DropdownMenuItem(
302− leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
303− text = { Text(stringResource(R.string.quote_reply)) },
304− onClick = {
305− expanded = false
306− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
307− quote(text)
308− },
309− )
310− }
311− onBranch?.let { branch ->
312− DropdownMenuItem(
313− leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null) },
314− text = { Text(stringResource(R.string.branch_session)) },
315− onClick = {
316− expanded = false
317− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
318− branch(text)
319− },
320− )
321− }
322− onViewSource?.let { viewSource ->
323− DropdownMenuItem(
324− leadingIcon = { Icon(Icons.Filled.Code, contentDescription = null) },
325− text = { Text(stringResource(R.string.view_source)) },
326− onClick = {
327− expanded = false
328− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
329− viewSource()
330− },
331− )
332− }
315+ MessageOverflowItems(
316+ text = text,
317+ onCopy = onCopy,
318+ onShare = onShare,
319+ onEdit = onEdit,
320+ onRegenerate = onRegenerate,
321+ onSpeak = onSpeak,
322+ speakLabel = speakLabel,
323+ onQuote = onQuote,
324+ onBranch = onBranch,
325+ onViewSource = onViewSource,
326+ onRevert = onRevert,
327+ onDismissMenu = { expanded = false },
328+ haptics = haptics,
329+ )
333330 }
334331 }
335332}
336333
334+/** The dropdown menu items for [MessageOverflow], extracted to keep that function's
335+ * cyclomatic complexity under detekt's threshold. Each item is gated on its callback (and
336+ * text where relevant) so only available actions appear. */
337+@Composable
338+private fun MessageOverflowItems(
339+ text: String,
340+ onCopy: (() -> Unit)?,
341+ onShare: (() -> Unit)?,
342+ onEdit: (() -> Unit)?,
343+ onRegenerate: (() -> Unit)?,
344+ onSpeak: (() -> Unit)?,
345+ speakLabel: String?,
346+ onQuote: ((String) -> Unit)?,
347+ onBranch: ((String) -> Unit)?,
348+ onViewSource: (() -> Unit)?,
349+ onRevert: (() -> Unit)?,
350+ onDismissMenu: () -> Unit,
351+ haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
352+) {
353+ if (text != null && onCopy != null) {
354+ DropdownMenuItem(
355+ leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
356+ text = { Text(stringResource(R.string.copy)) },
357+ onClick = {
358+ onDismissMenu()
359+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
360+ onCopy()
361+ },
362+ )
363+ }
364+ if (text != null && onShare != null) {
365+ DropdownMenuItem(
366+ leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
367+ text = { Text(stringResource(R.string.share_message)) },
368+ onClick = {
369+ onDismissMenu()
370+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
371+ onShare()
372+ },
373+ )
374+ }
375+ if (text != null && onEdit != null) {
376+ DropdownMenuItem(
377+ leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
378+ text = { Text(stringResource(R.string.edit_message)) },
379+ onClick = {
380+ onDismissMenu()
381+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
382+ onEdit()
383+ },
384+ )
385+ }
386+ if (onRegenerate != null) {
387+ DropdownMenuItem(
388+ leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) },
389+ text = { Text(stringResource(R.string.regenerate)) },
390+ onClick = {
391+ onDismissMenu()
392+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
393+ onRegenerate()
394+ },
395+ )
396+ }
397+ if (text != null && onSpeak != null && speakLabel != null) {
398+ DropdownMenuItem(
399+ leadingIcon = { Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = null) },
400+ text = { Text(speakLabel) },
401+ onClick = {
402+ onDismissMenu()
403+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
404+ onSpeak()
405+ },
406+ )
407+ }
408+ onQuote?.let { quote ->
409+ DropdownMenuItem(
410+ leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
411+ text = { Text(stringResource(R.string.quote_reply)) },
412+ onClick = {
413+ onDismissMenu()
414+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
415+ quote(text)
416+ },
417+ )
418+ }
419+ onBranch?.let { branch ->
420+ DropdownMenuItem(
421+ leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null) },
422+ text = { Text(stringResource(R.string.branch_session)) },
423+ onClick = {
424+ onDismissMenu()
425+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
426+ branch(text)
427+ },
428+ )
429+ }
430+ onViewSource?.let { viewSource ->
431+ DropdownMenuItem(
432+ leadingIcon = { Icon(Icons.Filled.Code, contentDescription = null) },
433+ text = { Text(stringResource(R.string.view_source)) },
434+ onClick = {
435+ onDismissMenu()
436+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
437+ viewSource()
438+ },
439+ )
440+ }
441+ if (onRevert != null) {
442+ DropdownMenuItem(
443+ leadingIcon = { Icon(Icons.Filled.Restore, contentDescription = null) },
444+ text = { Text(stringResource(R.string.revert_to_here)) },
445+ onClick = {
446+ onDismissMenu()
447+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
448+ onRevert()
449+ },
450+ )
451+ }
452+}
453+
337454/** A small "revert to before this message" icon button, shown next to Copy when a revert
338455 * handler is supplied. Reverting is undoable (see the chat's revert banner), so it acts
339456 * immediately without a confirmation dialog. */
@@ -638,10 +755,28 @@ private fun UserBubble(
638755 )
639756 }
640757 }
641− // Quote-reply / branch-a-new-session, tucked behind an overflow so the
642− // inline row stays compact. Only when there's prompt text to act on.
758+ // Per-message overflow: a visible icon button exposing the full action
759+ // set (copy/edit/share/quote/branch/revert) in one tap. Promoted from a
760+ // quote/branch-only menu so the actions are discoverable without relying on
761+ // the long-press gesture, which is consumed by the SelectionContainer on
762+ // selectable text and so only fires from the thin footer. The inline
763+ // copy/edit/revert icons above remain for the most common one-tap actions;
764+ // this overflow surfaces the rest and duplicates the common ones for
765+ // discoverability. Only when there's prompt text to act on.
643766 if (textToCopy != null) {
644− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
767+ MessageOverflow(
768+ text = textToCopy,
769+ onQuote = onQuote,
770+ onBranch = onBranch,
771+ onCopy = {
772+ copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
773+ },
774+ onEdit = if (onEdit != null) {
775+ { onEdit(textToCopy); if (hasAttachments) showToast(context, dropsAttachmentsMsg) }
776+ } else null,
777+ onRevert = onRevert,
778+ onShare = onShare,
779+ )
645780 }
646781 }
647782 }
@@ -845,6 +980,7 @@ private fun AssistantBlock(
845980 onRegenerate = onRegenerate,
846981 onContinue = onContinue,
847982 onViewSource = textToCopy?.let { { showSource = true } },
983+ onShare = onShare,
848984 )
849985 }
850986 AssistantMessageLongPressMenu(
@@ -999,6 +1135,7 @@ private fun AssistantActions(
9991135 onRegenerate: (() -> Unit)? = null,
10001136 onContinue: (() -> Unit)? = null,
10011137 onViewSource: (() -> Unit)? = null,
1138+ onShare: (() -> Unit)? = null,
10021139) {
10031140 val context = LocalContext.current
10041141 val haptics = LocalHapticFeedback.current
@@ -1068,11 +1205,47 @@ private fun AssistantActions(
10681205 }
10691206 }
10701207 // Assistant replies can be quoted into the composer or branched into a new session.
1071− // View source is also available here for assistant replies (in addition to the long-press
1072− // menu) so the action is discoverable without a long-press. MessageOverflow itself
1208+ // View source is also available here for assistant replies. The overflow exposes the
1209+ // full action set (copy/share/regenerate/read-aloud/quote/branch/view-source/revert)
1210+ // in one tap from a visible icon button — promoted from a quote/branch-only menu so
1211+ // the actions are discoverable without the long-press gesture (which is consumed by
1212+ // the SelectionContainer on selectable text). The inline copy/regenerate/TTS/revert
1213+ // icons above remain for the most common one-tap actions; this overflow surfaces the
1214+ // rest and duplicates the common ones for discoverability. MessageOverflow itself
10731215 // no-ops when all callbacks are null, so the guard only needs to check text.
10741216 if (textToCopy != null) {
1075− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch, onViewSource = onViewSource)
1217+ val speakForOverflow: (() -> Unit)? = if (onSpeak != null) {
1218+ {
1219+ when {
1220+ isSpeaking && ttsState == TtsState.PAUSED -> onResume?.invoke()
1221+ isSpeaking -> onPause?.invoke()
1222+ else -> onSpeak(textToCopy)
1223+ }
1224+ }
1225+ } else null
1226+ val speakLabelForOverflow: String? = if (onSpeak != null) {
1227+ stringResource(
1228+ when {
1229+ isSpeaking && ttsState == TtsState.PAUSED -> R.string.resume_reading
1230+ isSpeaking -> R.string.pause_reading
1231+ else -> R.string.read_aloud
1232+ },
1233+ )
1234+ } else null
1235+ MessageOverflow(
1236+ text = textToCopy,
1237+ onQuote = onQuote,
1238+ onBranch = onBranch,
1239+ onViewSource = onViewSource,
1240+ onCopy = {
1241+ copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
1242+ },
1243+ onRegenerate = onRegenerate,
1244+ onRevert = onRevert,
1245+ onShare = onShare,
1246+ onSpeak = speakForOverflow,
1247+ speakLabel = speakLabelForOverflow,
1248+ )
10761249 }
10771250 }
10781251}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+71−21
@@ -372,16 +372,20 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
372372 modifier = Modifier.padding(start = 6.dp),
373373 )
374374 // Wall-clock duration of the tool call (e.g. "· 2.3s"), shown when the server
375− // reports both start and end. Omitted while still running (no end yet) and when
376− // timing wasn't reported — a bare "· 0ms" would be noise. Placed inline with the
377− // tool name so a glance at the row tells both *what* and *how long*.
375+ // reports both start and end. Omitted while still running (no end yet), when
376+ // timing wasn't reported, and for sub-second runs (the ms figure is noise for
377+ // instant tools like a quick read/glob — see NetworkConfig.toolDurationHideBelowMs).
378+ // Placed inline with the tool name so a glance at the row tells both *what* and
379+ // *how long*.
378380 part.state.durationMs()?.let { ms ->
379− Text(
380− stringResource(R.string.tool_duration_format, formatToolDuration(ms)),
381− style = MaterialTheme.typography.labelSmall,
382− color = MaterialTheme.colorScheme.onSurfaceVariant,
383− modifier = Modifier.padding(start = 6.dp),
384− )
381+ if (ms >= NetworkConfig.toolDurationHideBelowMs) {
382+ Text(
383+ stringResource(R.string.tool_duration_format, formatToolDuration(ms)),
384+ style = MaterialTheme.typography.labelSmall,
385+ color = MaterialTheme.colorScheme.onSurfaceVariant,
386+ modifier = Modifier.padding(start = 6.dp),
387+ )
388+ }
385389 }
386390 }
387391 // A human-readable summary of what the tool was asked to do (e.g. "Reading
@@ -410,11 +414,17 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
410414 val pretty = remember(input) {
411415 runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
412416 }
417+ // Expand short inputs by default: for tools like bash/write/edit the command
418+ // is the most important info, and hiding it behind a tap inverts the priority.
419+ // Longer inputs stay collapsed to avoid dominating the bubble.
420+ val inputLines = remember(pretty) { pretty.lines().size }
421+ val autoExpand = inputLines <= NetworkConfig.toolInputAutoExpandLineThreshold
413422 CollapsibleDetail(
414423 label = inputLabel,
415424 detail = pretty,
416425 isDiff = false,
417426 keySuffix = "input",
427+ defaultExpanded = autoExpand,
418428 // The input block has a label row whose collapse toggle announces its state
419429 // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
420430 // rather than an empty state (the defaults are "").
@@ -439,6 +449,12 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
439449 // looksLikeDiff scans the whole string; memoize on `detail` so a recomposition that
440450 // doesn't change the content (e.g. the expand/collapse flip) doesn't re-scan.
441451 val isDiff = remember(detail) { looksLikeDiff(detail) }
452+ // Collapse when the output exceeds EITHER the char cap or the line cap. A line-based
453+ // threshold keeps the collapsed preview a predictable mobile height — a 4000-char
454+ // block of short lines can be 60+ lines and fill the screen, defeating the collapse.
455+ val lineCount = remember(detail) { detail.lines().size }
456+ val exceedsLimit = detail.length > COLLAPSED_LIMIT ||
457+ lineCount > NetworkConfig.toolOutputCollapsedLimitLines
442458 val collapsed = remember(detail, isDiff) {
443459 val head = detail.take(COLLAPSED_LIMIT)
444460 // Truncating a diff mid-line makes DiffView render a malformed final line, so
@@ -448,22 +464,22 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
448464 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
449465 val expandedState = stringResource(R.string.state_expanded)
450466 val collapsedState = stringResource(R.string.state_collapsed)
451− val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
467+ val display = if (expanded || !exceedsLimit) detail else collapsed
452468 // How many lines are hidden while collapsed. detail.lines() splits the whole
453469 // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
454470 // every recomposition, including each streaming update of a running tool.
455471 // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
456472 // 0 (a single long line cut mid-way) falls back to the generic label.
457− val moreLines = remember(detail, collapsed) {
458− if (detail.length > COLLAPSED_LIMIT) detail.lines().size - collapsed.lines().size else 0
473+ val moreLines = remember(detail, collapsed, lineCount) {
474+ if (exceedsLimit) lineCount - collapsed.lines().size else 0
459475 }
460476 CollapsibleDetail(
461477 label = null,
462478 detail = display,
463479 isDiff = isDiff,
464480 keySuffix = "output",
465− expanded = expanded || detail.length <= COLLAPSED_LIMIT,
466− onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
481+ expanded = expanded || !exceedsLimit,
482+ onToggleExpand = if (exceedsLimit) {
467483 { expanded = !expanded }
468484 } else null,
469485 moreLines = moreLines,
@@ -500,10 +516,13 @@ private fun CollapsibleDetail(
500516 onCopy: (() -> Unit)? = null,
501517 diffSaveKey: String? = null,
502518 isError: Boolean = false,
519+ /** Initial expanded state for the labeled (input) variant. The output variant ignores
520+ * this and uses [expanded]/[onToggleExpand] instead. */
521+ defaultExpanded: Boolean = false,
503522) {
504523 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
505524 if (label != null) {
506− var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
525+ var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(defaultExpanded) }
507526 Row(
508527 modifier = Modifier
509528 .fillMaxWidth()
@@ -719,6 +738,10 @@ private fun FileChip(
719738 * a main agent + a sub-agent) shows the cost breakdown per step. Compact and muted
720739 * to read as a footnote rather than a primary element. Returns nothing when the
721740 * step reported neither cost nor tokens (some steps report no usage).
741+ *
742+ * Collapsed by default behind a small toggle so a multi-step response isn't
743+ * cluttered with a cost line after each step; the aggregate in the footer is the
744+ * primary signal, and a user who wants the per-step breakdown can expand it.
722745 */
723746@Composable
724747private fun StepFinishSummary(part: StepFinishPart) {
@@ -734,10 +757,37 @@ private fun StepFinishSummary(part: StepFinishPart) {
734757 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
735758 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
736759 } ?: return
737− Text(
738− summary,
739− style = MaterialTheme.typography.labelSmall,
740− color = MaterialTheme.colorScheme.onSurfaceVariant,
741− modifier = Modifier.padding(top = 2.dp),
742− )
760+ val expandedState = stringResource(R.string.state_expanded)
761+ val collapsedState = stringResource(R.string.state_collapsed)
762+ var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
763+ Row(
764+ modifier = Modifier
765+ .fillMaxWidth()
766+ .defaultMinSize(minHeight = 48.dp)
767+ .clickable(role = Role.Button) { expanded = !expanded }
768+ .semantics { stateDescription = if (expanded) expandedState else collapsedState },
769+ verticalAlignment = Alignment.CenterVertically,
770+ ) {
771+ Icon(
772+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
773+ contentDescription = null,
774+ modifier = Modifier.size(14.dp),
775+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
776+ )
777+ if (expanded) {
778+ Text(
779+ summary,
780+ style = MaterialTheme.typography.labelSmall,
781+ color = MaterialTheme.colorScheme.onSurfaceVariant,
782+ modifier = Modifier.padding(start = 4.dp, top = 2.dp),
783+ )
784+ } else {
785+ Text(
786+ stringResource(R.string.step_summary_collapsed),
787+ style = MaterialTheme.typography.labelSmall,
788+ color = MaterialTheme.colorScheme.onSurfaceVariant,
789+ modifier = Modifier.padding(start = 4.dp),
790+ )
791+ }
792+ }
743793}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+21−14
@@ -87,9 +87,6 @@ fun PermissionDialog(
8787 // indefinitely (the foreground service holds the process alive). LaunchedEffect keyed on
8888 // permission.id so a fresh prompt resets the timer. Disabled when the timeout is 0.
8989 val autoRejectMs = NetworkConfig.permissionAutoRejectMs
90− // Final-warning threshold: show a per-second countdown in the last 10s so the user isn't
91− // surprised by a silent auto-reject.
92− val warningThresholdMs = (autoRejectMs - 10_000L).coerceAtLeast(0L)
9390 LaunchedEffect(permission.id, autoRejectMs) {
9491 if (autoRejectMs <= 0L) return@LaunchedEffect
9592 val tickMs = 1000L
@@ -102,14 +99,22 @@ fun PermissionDialog(
10299 respond(PermissionResponse.REJECT)
103100 }
104101 val showReminder = elapsedMs >= NetworkConfig.permissionReminderThresholdMs
105− // In the final 10 seconds, show a per-second countdown instead of the minute reminder.
106− val showSecondsWarning = autoRejectMs > 0L && elapsedMs >= warningThresholdMs
102+ // In the final 60 seconds, switch to a per-second countdown so the imminent auto-reject
103+ // is clearly signaled (a full minute of per-second countdown, not just the last 10s,
104+ // so the user isn't surprised at the 10s mark by a sudden format change).
105+ val showSecondsWarning = autoRejectMs > 0L && elapsedMs >= autoRejectMs - 60_000L
107106 AlertDialog(
108107 // Back press routes here (dismissOnBackPress defaults true) and is treated as an
109108 // explicit reject — the safe default. Tap-outside is disabled below so it can't
110109 // dismiss silently. A host-composition BackHandler doesn't work: AlertDialog
111110 // renders in its own window whose back dispatcher never reaches the host.
112− onDismissRequest = { respond(PermissionResponse.REJECT) },
111+ // A haptic fires so an accidental back-press/gesture-mis-tap is at least perceptible
112+ // — without it the run would stop with no visible/audible feedback that the back
113+ // press was the cause.
114+ onDismissRequest = {
115+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
116+ respond(PermissionResponse.REJECT)
117+ },
113118 properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
114119 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
115120 title = {
@@ -212,6 +217,16 @@ fun PermissionDialog(
212217 },
213218 modifier = Modifier.fillMaxWidth(),
214219 ) { Text(stringResource(R.string.allow_for_session)) }
220+ // Explain the scope of "Always allow" so the user understands what they're
221+ // granting (this tool + pattern in every session) BEFORE choosing it. Placed
222+ // above the button (informed-consent ordering) so a top-down reader sees the
223+ // scope before reaching the action.
224+ Text(
225+ stringResource(R.string.always_allow_scope),
226+ style = MaterialTheme.typography.bodySmall,
227+ color = MaterialTheme.colorScheme.onSurfaceVariant,
228+ modifier = Modifier.fillMaxWidth(),
229+ )
215230 OutlinedButton(
216231 onClick = {
217232 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
@@ -219,14 +234,6 @@ fun PermissionDialog(
219234 },
220235 modifier = Modifier.fillMaxWidth(),
221236 ) { Text(stringResource(R.string.always_allow)) }
222− // Explain the scope of "Always allow" so the user understands what they're
223− // granting (this tool + pattern in every session) before choosing it.
224− Text(
225− stringResource(R.string.always_allow_scope),
226− style = MaterialTheme.typography.bodySmall,
227− color = MaterialTheme.colorScheme.onSurfaceVariant,
228− modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
229− )
230237 // Reject is an OutlinedButton (not a low-prominence TextButton) in the
231238 // same column so the actions are visually balanced — the prior layout buried
232239 // Reject as a small dismiss-button, nudging users toward granting. Reject
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+13−8
@@ -11,6 +11,7 @@ import androidx.compose.runtime.State
1111import androidx.compose.runtime.mutableStateOf
1212import androidx.compose.runtime.remember
1313import androidx.compose.ui.platform.LocalContext
14+import soy.iko.opencode.data.network.NetworkConfig
1415
1516/**
1617 * Remember a [TtsController] scoped to the current composition; it shuts the engine down
@@ -185,11 +186,15 @@ class TtsController(context: Context) : RememberObserver {
185186 /**
186187 * Split [text] into segments no longer than [TextToSpeech.getMaxSpeechInputLength] so
187188 * every part can actually be enqueued. Prefers sentence/paragraph then whitespace
188− * boundaries, packing greedily, and hard-slices any single piece that still overflows.
189+ * boundaries, packing greedily up to [NetworkConfig.ttsChunkTargetMaxChars] (a tighter
190+ * cap than the hard limit) so pause/resume restarts at a finer granularity — a pause
191+ * mid-chunk restarts the whole chunk, so smaller chunks mean less replay on resume.
192+ * Hard-slices any single piece that still overflows the absolute max.
189193 */
190194 private fun chunkForTts(text: String): List<String> {
191− val max = TextToSpeech.getMaxSpeechInputLength().coerceAtLeast(1)
192− if (text.length <= max) return listOf(text)
195+ val hardMax = TextToSpeech.getMaxSpeechInputLength().coerceAtLeast(1)
196+ val packTarget = NetworkConfig.ttsChunkTargetMaxChars.coerceAtMost(hardMax)
197+ if (text.length <= packTarget) return listOf(text)
193198 val chunks = ArrayList<String>()
194199 val current = StringBuilder()
195200 fun flush() {
@@ -197,16 +202,16 @@ class TtsController(context: Context) : RememberObserver {
197202 }
198203 for (piece in text.split(Regex("(?<=[.!?\\n])\\s+"))) {
199204 var p = piece
200− // A single piece with no usable boundary can still exceed the limit: hard-slice it.
201− while (p.length > max) {
205+ // A single piece with no usable boundary can still exceed the hard limit: hard-slice it.
206+ while (p.length > hardMax) {
202207 flush()
203− chunks.add(p.substring(0, max))
204− p = p.substring(max)
208+ chunks.add(p.substring(0, hardMax))
209+ p = p.substring(hardMax)
205210 }
206211 when {
207212 p.isEmpty() -> Unit
208213 current.isEmpty() -> current.append(p)
209− current.length + 1 + p.length <= max -> current.append(' ').append(p)
214+ current.length + 1 + p.length <= packTarget -> current.append(' ').append(p)
210215 else -> { flush(); current.append(p) }
211216 }
212217 }
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+78−19
@@ -42,14 +42,21 @@ import soy.iko.opencode.data.network.EventStreamClient
4242import soy.iko.opencode.data.network.NetworkConfig
4343import soy.iko.opencode.di.AppContainer
4444
45+/** Attempt count at which the banner switches to "Reconnecting (attempt N)…". See
46+ * NetworkConfig.sseReconnectAttemptLabelThreshold. */
47+private val RECONNECT_ATTEMPT_LABEL_THRESHOLD get() = NetworkConfig.sseReconnectAttemptLabelThreshold
48+
4549/**
4650 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
4751 * session list so a dropped stream is visible everywhere, not just mid-conversation.
4852 *
4953 * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
5054 * when [onRetry] is supplied, so the user can re-connect without hunting for the
51− * chat screen's reconnect button or navigating to the server list. Transient
52− * connecting/reconnecting states remain non-interactive (the system is already trying).
55+ * chat screen's reconnect button or navigating to the server list. On an auth failure
56+ * ([ConnectionState.AuthFailed]) an "Edit credentials" button is offered instead (via
57+ * [onEditCredentials]) — retrying with the same bad credentials is futile, so the
58+ * recovery path is to edit the server profile. Transient connecting/reconnecting states
59+ * remain non-interactive (the system is already trying).
5360 */
5461@Composable
5562fun ConnectionBanner(
@@ -57,6 +64,8 @@ fun ConnectionBanner(
5764 modifier: Modifier = Modifier,
5865 isOnline: Boolean = true,
5966 onRetry: (() -> Unit)? = null,
67+ onEditCredentials: (() -> Unit)? = null,
68+ reconnectAttempts: Int = 0,
6069) {
6170 // When the device itself has no connectivity, surface that distinctly (instead of
6271 // the SSE state) — a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
@@ -65,8 +74,18 @@ fun ConnectionBanner(
6574 stringResource(R.string.offline)
6675 } else {
6776 when (state) {
68− EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
69− EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
77+ EventStreamClient.ConnectionState.Connecting ->
78+ if (reconnectAttempts >= RECONNECT_ATTEMPT_LABEL_THRESHOLD) {
79+ stringResource(R.string.reconnect_attempt, reconnectAttempts)
80+ } else {
81+ stringResource(R.string.connecting)
82+ }
83+ EventStreamClient.ConnectionState.Disconnected ->
84+ if (reconnectAttempts >= RECONNECT_ATTEMPT_LABEL_THRESHOLD) {
85+ stringResource(R.string.reconnect_attempt, reconnectAttempts)
86+ } else {
87+ stringResource(R.string.reconnecting)
88+ }
7089 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
7190 EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
7291 EventStreamClient.ConnectionState.Connected -> null
@@ -157,26 +176,56 @@ fun ConnectionBanner(
157176 .padding(start = 6.dp)
158177 .weight(1f, fill = false),
159178 )
160− // Inline retry on the Failed banner so the user can recover without
161− // leaving the screen. Hidden during transient states (the system is
162− // already reconnecting), when offline (retry can't help), and when no
163− // callback is wired (callers that don't have a reconnect path).
164− if (isServerFailure && !isOffline && onRetry != null) {
165− TextButton(
166− onClick = onRetry,
167− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
168− modifier = Modifier
169− .defaultMinSize(minHeight = 48.dp)
170− .testTag("connection_retry"),
171− ) {
172− Text(stringResource(R.string.retry_now), color = onContainer)
173− }
179+ // Inline recovery action on a hard failure so the user can recover without
180+ // leaving the screen. Hidden during transient states (the system is already
181+ // reconnecting), when offline (retry can't help), and when no callback is wired.
182+ if (isServerFailure && !isOffline) {
183+ ConnectionRecoveryButton(
184+ state = state,
185+ onRetry = onRetry,
186+ onEditCredentials = onEditCredentials,
187+ onContainer = onContainer,
188+ )
174189 }
175190 }
176191 }
177192 }
178193}
179194
195+/** Renders the inline recovery button for a hard connection failure. For an auth failure
196+ * (401/403), retrying with the same bad credentials is futile — offer "Edit credentials"
197+ * instead. For an endpoint failure, "Retry now" re-attempts the connection. Extracted from
198+ * [ConnectionBanner] to keep that function's cyclomatic complexity under detekt's threshold. */
199+@Composable
200+private fun ConnectionRecoveryButton(
201+ state: EventStreamClient.ConnectionState,
202+ onRetry: (() -> Unit)?,
203+ onEditCredentials: (() -> Unit)?,
204+ onContainer: androidx.compose.ui.graphics.Color,
205+) {
206+ if (state == EventStreamClient.ConnectionState.AuthFailed && onEditCredentials != null) {
207+ TextButton(
208+ onClick = onEditCredentials,
209+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
210+ modifier = Modifier
211+ .defaultMinSize(minHeight = 48.dp)
212+ .testTag("connection_edit_credentials"),
213+ ) {
214+ Text(stringResource(R.string.edit_credentials), color = onContainer)
215+ }
216+ } else if (onRetry != null) {
217+ TextButton(
218+ onClick = onRetry,
219+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
220+ modifier = Modifier
221+ .defaultMinSize(minHeight = 48.dp)
222+ .testTag("connection_retry"),
223+ ) {
224+ Text(stringResource(R.string.retry_now), color = onContainer)
225+ }
226+ }
227+}
228+
180229/**
181230 * Convenience wrapper that collects the SSE connection state and device connectivity from
182231 * [container] and renders a [ConnectionBanner] aligned to the top center of the calling
@@ -186,7 +235,10 @@ fun ConnectionBanner(
186235 */
187236@OptIn(ExperimentalCoroutinesApi::class)
188237@Composable
189−fun BoxScope.ConnectionBannerFor(container: AppContainer) {
238+fun BoxScope.ConnectionBannerFor(
239+ container: AppContainer,
240+ onEditCredentials: (() -> Unit)? = null,
241+) {
190242 // flatMapLatest without stateIn: collectAsStateWithLifecycle handles the lifecycle and
191243 // gives an initial value. No ViewModel scope needed, so this works on any screen.
192244 val connectionState by produceState(
@@ -197,11 +249,18 @@ fun BoxScope.ConnectionBannerFor(container: AppContainer) {
197249 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
198250 .collect { value = it }
199251 }
252+ val reconnectAttempts by produceState(initialValue = 0, container) {
253+ container.activeConnection
254+ .flatMapLatest { it?.events?.reconnectAttempts ?: flowOf(0) }
255+ .collect { value = it }
256+ }
200257 val isOnline by container.isOnline.collectAsStateWithLifecycle()
201258 ConnectionBanner(
202259 state = connectionState,
203260 modifier = Modifier.align(Alignment.TopCenter),
204261 isOnline = isOnline,
205262 onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
263+ onEditCredentials = onEditCredentials,
264+ reconnectAttempts = reconnectAttempts,
206265 )
207266}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+7−8
@@ -68,8 +68,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
6868import androidx.compose.runtime.setValue
6969import androidx.compose.ui.Alignment
7070import androidx.compose.ui.Modifier
71−import androidx.compose.ui.focus.FocusRequester
72−import androidx.compose.ui.focus.focusRequester
7371import androidx.compose.ui.graphics.Color
7472import androidx.compose.ui.graphics.graphicsLayer
7573import androidx.compose.ui.platform.LocalContext
@@ -171,7 +169,8 @@ fun FileBrowserScreen(
171169 .graphicsLayer { scaleX = scale; scaleY = scale },
172170 ) {
173171 // Surface SSE connection state so a dropped stream is visible while browsing
174− // files, not just on the chat/session screens.
172+ // files, not just on the chat/session screens. Wrapped in a Box because
173+ // ConnectionBannerFor uses BoxScope.align(TopCenter) for the banner's placement.
175174 Box(modifier = Modifier.fillMaxWidth()) {
176175 ConnectionBannerFor(container)
177176 }
@@ -182,17 +181,17 @@ fun FileBrowserScreen(
182181 modifier = Modifier.fillMaxWidth(),
183182 )
184183 val keyboardController = LocalSoftwareKeyboardController.current
185− // Search is the primary reason to open this screen, so focus the field on first
186− // composition. Focus only (no forced keyboard) so it doesn't fight the breadcrumb/listing.
187− val searchFocus = remember { FocusRequester() }
188− LaunchedEffect(Unit) { runCatchingCancellable { searchFocus.requestFocus() } }
184+ // Don't auto-focus the search field on entry: doing so raises the IME keyboard
185+ // over the directory listing (the other primary function of this screen) and
186+ // forces a browse-first user to dismiss it. The session/server lists don't
187+ // auto-focus either, so this is also a consistency fix. The field is still
188+ // prominent at the top and a tap focuses it as expected.
189189 OutlinedTextField(
190190 value = state.query,
191191 onValueChange = vm::setQuery,
192192 modifier = Modifier
193193 .fillMaxWidth()
194194 .padding(start = 12.dp, end = 12.dp, top = 12.dp)
195− .focusRequester(searchFocus)
196195 .testTag("file_search"),
197196 label = {
198197 Text(
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+16−1
@@ -38,6 +38,7 @@ import androidx.compose.foundation.text.KeyboardOptions
3838import androidx.compose.foundation.text.selection.SelectionContainer
3939import androidx.compose.material.icons.Icons
4040import androidx.compose.material.icons.automirrored.filled.ArrowBack
41+import androidx.compose.material.icons.automirrored.filled.Chat
4142import androidx.compose.material.icons.automirrored.filled.WrapText
4243import androidx.compose.material.icons.filled.Close
4344import androidx.compose.material.icons.filled.ContentCopy
@@ -295,6 +296,14 @@ fun FileViewScreen(
295296 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
296297 }
297298 },
299+ onOpenInChat = {
300+ // Start a new session with this file attached as an @path reference,
301+ // mirroring the file browser row's "Open in chat" action — the viewer is
302+ // the more natural place for it since the user is already looking at the file.
303+ container.setPendingShare("@$path")
304+ container.requestNewSession()
305+ onBack()
306+ },
298307 )
299308 },
300309 snackbarHost = { SnackbarHost(snackbar) },
@@ -505,6 +514,7 @@ private fun FileViewTopBar(
505514 onCopyPath: () -> Unit,
506515 onShare: () -> Unit,
507516 onOpenExternally: () -> Unit,
517+ onOpenInChat: () -> Unit,
508518) {
509519 TopAppBar(
510520 title = {
@@ -567,7 +577,7 @@ private fun FileViewTopBar(
567577 // title isn't crowded off screen by five inline icons on a phone.
568578 var menuExpanded by remember { mutableStateOf(false) }
569579 IconButton(onClick = { menuExpanded = true }) {
570− Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
580+ Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options_for, filename))
571581 }
572582 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
573583 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
@@ -600,6 +610,11 @@ private fun FileViewTopBar(
600610 onClick = { onCopyPath(); menuExpanded = false },
601611 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
602612 )
613+ DropdownMenuItem(
614+ text = { Text(stringResource(R.string.open_in_chat)) },
615+ onClick = { onOpenInChat(); menuExpanded = false },
616+ leadingIcon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
617+ )
603618 DropdownMenuItem(
604619 text = { Text(stringResource(R.string.share)) },
605620 onClick = { onShare(); menuExpanded = false },
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+9−5
@@ -34,7 +34,7 @@ import androidx.compose.material3.DropdownMenu
3434import androidx.compose.material3.DropdownMenuItem
3535import androidx.compose.material3.ExperimentalMaterial3Api
3636import androidx.compose.material3.FilterChip
37−import androidx.compose.material3.FloatingActionButton
37+import androidx.compose.material3.ExtendedFloatingActionButton
3838import androidx.compose.material3.Icon
3939import androidx.compose.material3.IconButton
4040import androidx.compose.material3.MaterialTheme
@@ -112,11 +112,15 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
112112 snackbarHost = { SnackbarHost(snackbar) },
113113 floatingActionButton = {
114114 // Register a new MCP server dynamically (POST /mcp). Hidden until a connection is
115− // established — adding requires an active server.
115+ // established — adding requires an active server. Extended FAB (icon + label) matches
116+ // the session list ("New session") and server list ("Add server") so a first-time
117+ // user isn't left guessing what the + does.
116118 if (state !is McpViewModel.State.Disconnected) {
117− FloatingActionButton(onClick = { showAddDialog = true }) {
118− Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.mcp_add))
119− }
119+ ExtendedFloatingActionButton(
120+ onClick = { showAddDialog = true },
121+ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
122+ text = { Text(stringResource(R.string.mcp_add_server)) },
123+ )
120124 }
121125 },
122126 ) { padding ->
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+4−4
@@ -23,7 +23,6 @@ import kotlinx.serialization.json.putJsonArray
2323import kotlinx.serialization.json.putJsonObject
2424import soy.iko.opencode.di.AppContainer
2525import soy.iko.opencode.util.runCatchingCancellable
26−import soy.iko.opencode.util.safeExceptionSummary
2726
2827/** The shape of an MCP server the user is registering from the Add dialog. */
2928enum class McpKind { LOCAL, REMOTE }
@@ -39,8 +38,9 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
3938 sealed interface State {
4039 data object Loading : State
4140 data object Disconnected : State
42− /** MCP servers failed to load. [message] carries a short, safe summary so the user
43− * can tell an auth failure from a transient network drop or an old server without /mcp. */
41+ /** MCP servers failed to load. [message] carries a localized, human-friendly summary
42+ * produced by [AppContainer.friendlyError] (auth vs network vs timeout vs …) so the
43+ * user can tell failure modes apart. Matches every other screen's error handling. */
4444 data class Error(val message: String? = null) : State
4545 data class Ready(val servers: List<McpServerInfo>) : State
4646 }
@@ -100,7 +100,7 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
100100 }
101101 }
102102 .onSuccess { _state.value = State.Ready(it) }
103− .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
103+ .onFailure { _state.value = State.Error(container.friendlyError(it)) }
104104 _refreshing.value = false
105105 }
106106
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+25−4
@@ -35,6 +35,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
3535import androidx.compose.material3.FilterChip
3636import androidx.compose.material3.Icon
3737import androidx.compose.material3.IconButton
38+import androidx.compose.material3.LinearProgressIndicator
3839import androidx.compose.material3.MaterialTheme
3940import androidx.compose.material3.OutlinedTextField
4041import androidx.compose.material3.Scaffold
@@ -154,18 +155,24 @@ fun GlobalSearchScreen(
154155 Box(modifier = Modifier.fillMaxSize()) {
155156 ConnectionBannerFor(container)
156157 val searchingLabel = stringResource(R.string.searching)
158+ // When a re-search runs on top of existing results, keep the prior results
159+ // visible with a slim top progress bar (matching the file browser) instead of
160+ // blanking the list to a full-screen spinner — incremental typing shouldn't
161+ // flash an empty screen each keystroke. The full-screen spinner is only used
162+ // for the very first search (no results yet).
163+ val showResultsWhileSearching = state.searching && state.hasSearched && state.results.isNotEmpty()
157164 // Crossfade between content states so transitions read as a smooth fade instead
158165 // of an instant snap. Matches the session list's Crossfade pattern; reduced
159166 // motion is honored by Crossfade's default spec.
160167 val stateKey = globalSearchStateKey(state)
161− @Suppress("UnusedCrossfadeTargetStateParameter")
162− Crossfade(
168+ @Suppress("UnusedCrossfadeTargetStateParameter")
169+ Crossfade(
163170 targetState = stateKey,
164171 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
165172 label = "global_search_state",
166173 ) {
167174 when {
168− state.searching -> Column(
175+ state.searching && !showResultsWhileSearching -> Column(
169176 modifier = Modifier.align(Alignment.Center),
170177 horizontalAlignment = Alignment.CenterHorizontally,
171178 ) {
@@ -267,6 +274,18 @@ fun GlobalSearchScreen(
267274 }
268275 }
269276 }
277+ // Slim progress bar overlay for an in-flight re-search that keeps the prior
278+ // results visible underneath (matches the file browser's pattern). Only shown
279+ // when results are already on screen; the first-search spinner is handled by
280+ // the Crossfade branch above.
281+ if (showResultsWhileSearching) {
282+ LinearProgressIndicator(
283+ modifier = Modifier
284+ .align(Alignment.TopCenter)
285+ .fillMaxWidth()
286+ .semantics { contentDescription = searchingLabel },
287+ )
288+ }
270289 }
271290 }
272291 }
@@ -274,8 +293,10 @@ fun GlobalSearchScreen(
274293
275294/** Maps the global-search view state to a stable string key for the Crossfade, so the screen's
276295 * content-state transitions fade smoothly. Extracted from [GlobalSearchScreen] to keep its
277− * cyclomatic complexity under the detekt threshold. */
296+ * cyclomatic complexity under the detekt threshold. A re-search that keeps prior results
297+ * visible maps to "results" (not "searching") so the Crossfade doesn't fade the list out. */
278298private fun globalSearchStateKey(state: GlobalSearchState): String = when {
299+ state.searching && state.hasSearched && state.results.isNotEmpty() -> "results"
279300 state.searching -> "searching"
280301 state.error != null -> "error"
281302 state.hasSearched && state.results.isEmpty() -> "no_matches"
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+17−1
@@ -41,6 +41,7 @@ import androidx.compose.material3.Switch
4141import androidx.compose.material3.Text
4242import androidx.compose.material3.TextButton
4343import androidx.compose.material3.TopAppBar
44+import androidx.compose.material3.TopAppBarDefaults
4445import androidx.compose.runtime.Composable
4546import androidx.compose.runtime.LaunchedEffect
4647import androidx.compose.runtime.getValue
@@ -52,6 +53,7 @@ import androidx.compose.ui.Alignment
5253import androidx.compose.ui.Modifier
5354import androidx.compose.ui.focus.FocusRequester
5455import androidx.compose.ui.focus.focusRequester
56+import androidx.compose.ui.input.nestedscroll.nestedScroll
5557import androidx.compose.ui.platform.LocalContext
5658import androidx.compose.ui.platform.LocalSoftwareKeyboardController
5759import androidx.compose.ui.platform.testTag
@@ -109,6 +111,10 @@ fun ServerEditScreen(
109111 if (!state.saving) showDiscardConfirm = true
110112 }
111113
114+ // Collapsing/lifting top bar so the long form scrolls under it, matching Settings and
115+ // Diagnostics (the form can be tall: Base URL + Discovery + Auth + Test + Connect + Save +
116+ // Label + Security).
117+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
112118 Scaffold(
113119 topBar = {
114120 TopAppBar(
@@ -128,10 +134,15 @@ fun ServerEditScreen(
128134 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
129135 }
130136 if (showDeleteConfirm) {
137+ // Name the server (matching the server list's delete dialog, which
138+ // includes displayLabel) so the user can verify which profile they're
139+ // deleting before confirming. displayLabel falls back to baseUrl when
140+ // no label is set, so there's always an identifiable name.
141+ val displayName = state.label.takeIf { it.isNotBlank() } ?: state.baseUrl
131142 AlertDialog(
132143 onDismissRequest = { showDeleteConfirm = false },
133144 title = { Text(stringResource(R.string.delete_server)) },
134− text = { Text(stringResource(R.string.delete_server_confirm)) },
145+ text = { Text(stringResource(R.string.remove_server_text, displayName)) },
135146 confirmButton = {
136147 TextButton(onClick = {
137148 showDeleteConfirm = false
@@ -145,6 +156,7 @@ fun ServerEditScreen(
145156 }
146157 }
147158 },
159+ scrollBehavior = scrollBehavior,
148160 )
149161 },
150162 ) { padding ->
@@ -159,6 +171,7 @@ fun ServerEditScreen(
159171 passwordVisible = passwordVisible,
160172 onTogglePassword = { passwordVisible = !passwordVisible },
161173 padding = padding,
174+ scrollBehavior = scrollBehavior,
162175 vm = vm,
163176 onDone = onDone,
164177 )
@@ -183,12 +196,14 @@ fun ServerEditScreen(
183196 }
184197}
185198
199+@OptIn(ExperimentalMaterial3Api::class)
186200@Composable
187201private fun ServerEditForm(
188202 state: ServerEditState,
189203 passwordVisible: Boolean,
190204 onTogglePassword: () -> Unit,
191205 padding: androidx.compose.foundation.layout.PaddingValues,
206+ scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
192207 vm: ServerEditViewModel,
193208 onDone: () -> Unit,
194209) {
@@ -218,6 +233,7 @@ private fun ServerEditForm(
218233 .wrapContentWidth(Alignment.CenterHorizontally)
219234 .widthIn(max = 600.dp)
220235 .padding(16.dp)
236+ .nestedScroll(scrollBehavior.nestedScrollConnection)
221237 .verticalScroll(rememberScrollState()),
222238 verticalArrangement = Arrangement.spacedBy(12.dp),
223239 ) {
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+27−5
@@ -292,11 +292,33 @@ fun ServerListScreen(
292292 Column(modifier = Modifier.fillMaxSize().padding(top = padding.calculateTopPadding())) {
293293 if (reconnecting && profiles.isNotEmpty()) {
294294 val reconnectingLabel = stringResource(R.string.reconnecting)
295− LinearProgressIndicator(
296− modifier = Modifier
297− .fillMaxWidth()
298− .semantics { contentDescription = reconnectingLabel },
299− )
295+ // Add a visible text label so a sighted user sees the bar's meaning (a bare
296+ // progress bar is ambiguous — could be loading). Names the server so the user
297+ // knows which profile is reconnecting.
298+ val serverName = activeConnection?.profile?.displayLabel
299+ val visibleText = if (serverName != null) {
300+ stringResource(R.string.reconnecting_to, serverName)
301+ } else {
302+ reconnectingLabel
303+ }
304+ Row(
305+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
306+ verticalAlignment = Alignment.CenterVertically,
307+ ) {
308+ LinearProgressIndicator(
309+ modifier = Modifier
310+ .weight(1f, fill = false)
311+ .padding(end = 8.dp)
312+ .semantics { contentDescription = reconnectingLabel },
313+ )
314+ Text(
315+ visibleText,
316+ style = MaterialTheme.typography.labelSmall,
317+ color = MaterialTheme.colorScheme.onSurfaceVariant,
318+ maxLines = 1,
319+ overflow = TextOverflow.Ellipsis,
320+ )
321+ }
300322 }
301323 // Crossfade between content states (loading/empty/list) so the transition reads as a
302324 // smooth fade instead of an instant snap. Matches the session list's Crossfade
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+15−5
@@ -141,11 +141,21 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
141141 result.fold(
142142 onSuccess = { ok ->
143143 // probeWithCredentials returns false on 401/403: the server was
144− // reached but the credentials were rejected. Surface that as a
145− // failure rather than a false "Connected", so the user isn't
146− // misled into thinking auth is fine.
147− if (ok) ProbeTestResult.Success(latency)
148− else ProbeTestResult.Failed(container.string(R.string.connection_failed))
144+ // reached but auth failed. Distinguish "server requires auth but
145+ // no credentials were sent" from "credentials were sent and
146+ // rejected" so the user gets actionable feedback (enter creds vs
147+ // fix creds) instead of a single ambiguous "check credentials".
148+ if (ok) {
149+ ProbeTestResult.Success(latency)
150+ } else {
151+ val hasCreds = !profile.username.isNullOrBlank() || !profile.password.isNullOrEmpty()
152+ val msg = if (hasCreds) {
153+ container.string(R.string.credentials_incorrect)
154+ } else {
155+ container.string(R.string.server_requires_auth)
156+ }
157+ ProbeTestResult.Failed(msg)
158+ }
149159 },
150160 onFailure = { ProbeTestResult.Failed(container.friendlyError(it)) },
151161 ),
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+54−30
@@ -154,6 +154,7 @@ fun SessionListScreen(
154154 onOpenSettings: () -> Unit,
155155 onAddServer: () -> Unit,
156156 onOpenSearch: () -> Unit = {},
157+ onEditProfile: ((String) -> Unit)? = null,
157158 // Incremented by a host (the two-pane empty-detail pane) to open the new-session
158159 // directory picker, so that pane shares this screen's dialog instead of bypassing it.
159160 externalNewSessionTrigger: Int = 0,
@@ -166,6 +167,7 @@ fun SessionListScreen(
166167 val profiles by vm.profiles.collectAsStateWithLifecycle()
167168 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
168169 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
170+ val reconnectAttempts by vm.reconnectAttempts.collectAsStateWithLifecycle()
169171 val unread by vm.unread.collectAsStateWithLifecycle()
170172 val mutedSessions by container.mutedSessions.collectAsStateWithLifecycle()
171173 val creating by vm.creating.collectAsStateWithLifecycle()
@@ -435,7 +437,13 @@ fun SessionListScreen(
435437 DropdownMenuItem(
436438 text = { Text(stringResource(R.string.mark_all_read)) },
437439 leadingIcon = { Icon(Icons.Filled.DoneAll, contentDescription = null) },
438− onClick = { showMainMenu = false; container.clearAllUnread() },
440+ onClick = {
441+ showMainMenu = false
442+ // Route through the VM so a transient confirmation snackbar
443+ // fires (matching the selection-mode bulk-mark-read), instead
444+ // of the badges vanishing silently with no feedback.
445+ vm.markAllRead(unread.keys)
446+ },
439447 )
440448 }
441449 DropdownMenuItem(
@@ -509,6 +517,11 @@ fun SessionListScreen(
509517 state = connectionState,
510518 isOnline = isOnline,
511519 onRetry = { vm.retryConnection() },
520+ onEditCredentials = {
521+ val profileId = activeConnection?.profile?.id
522+ if (profileId != null) onEditProfile?.invoke(profileId)
523+ },
524+ reconnectAttempts = reconnectAttempts,
512525 )
513526 Box(modifier = Modifier.fillMaxSize()) {
514527 SessionListBody(
@@ -899,16 +912,20 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
899912 // list away from recency, so date headers would mislead).
900913 val showGroups = state.sortMode == SessionSortMode.RECENT &&
901914 !inSelection && state.query.isEmpty() && state.directoryFilter == null
902− // Resolve section labels once (in the @Composable scope) so buildGroupedEntries
903− // can stay a plain function. Captured into the file-level lateinit fields.
904− pinnedLabel = stringResource(R.string.session_pinned)
905− todayLabel = stringResource(R.string.today)
906− yesterdayLabel = stringResource(R.string.yesterday)
907− lastWeekLabel = stringResource(R.string.last_week)
908− olderLabel = stringResource(R.string.older)
915+ // Resolve section labels once (in the @Composable scope) so buildGroupedSegments
916+ // can stay a plain function. Passed in as a value (not captured via file-level
917+ // lateinit fields) so the function's contract is explicit and can't throw
918+ // UninitializedPropertyAccessException if call ordering ever changes.
919+ val dateLabels = DateGroupLabels(
920+ pinned = stringResource(R.string.session_pinned),
921+ today = stringResource(R.string.today),
922+ yesterday = stringResource(R.string.yesterday),
923+ lastWeek = stringResource(R.string.last_week),
924+ older = stringResource(R.string.older),
925+ )
909926 val groupedSegments = remember(nodes, renderCap, state.pinnedIds, showGroups) {
910927 val capped = if (nodes.size <= renderCap) nodes else nodes.take(renderCap)
911− buildGroupedSegments(capped, state.pinnedIds, showGroups)
928+ buildGroupedSegments(capped, state.pinnedIds, showGroups, dateLabels)
912929 }
913930 PullToRefreshBox(
914931 isRefreshing = refreshing,
@@ -1122,6 +1139,24 @@ private fun sessionDateGroup(session: soy.iko.opencode.data.model.Session, now:
11221139 }
11231140}
11241141
1142+/** Resolved date-group labels (resolved once in a @Composable scope via [stringResource] and
1143+ * passed into [buildGroupedSegments] so it can stay a plain non-@Composable function). */
1144+private data class DateGroupLabels(
1145+ val pinned: String,
1146+ val today: String,
1147+ val yesterday: String,
1148+ val lastWeek: String,
1149+ val older: String,
1150+)
1151+
1152+/** Resolve a date-group key to its display label using [labels]. */
1153+private fun dateGroupLabel(group: String, labels: DateGroupLabels): String = when (group) {
1154+ "today" -> labels.today
1155+ "yesterday" -> labels.yesterday
1156+ "last_week" -> labels.lastWeek
1157+ else -> labels.older
1158+}
1159+
11251160/** Build the grouped entry list for the LazyColumn. Inserts a "Pinned" header before pinned
11261161 * sessions (when [showGroups] is true and pinned sessions exist), and date-group headers
11271162 * (Today / Yesterday / Last week / Older) between unpinned sessions when [showGroups] is
@@ -1132,6 +1167,7 @@ private fun buildGroupedSegments(
11321167 nodes: List<SessionNode>,
11331168 pinnedIds: Set<String>,
11341169 showGroups: Boolean,
1170+ labels: DateGroupLabels,
11351171): List<Pair<SessionListEntry.Header?, List<SessionListEntry.Node>>> {
11361172 if (nodes.isEmpty()) return emptyList()
11371173 val now = System.currentTimeMillis()
@@ -1146,7 +1182,7 @@ private fun buildGroupedSegments(
11461182 fun maybeAddPinnedSegment(): MutableList<SessionListEntry.Node>? {
11471183 if (pinnedIds.isEmpty()) return null
11481184 if (!pinnedHeaderAdded[0]) {
1149− ensureSegment(SessionListEntry.Header("__pinned", pinnedLabel))
1185+ ensureSegment(SessionListEntry.Header("__pinned", labels.pinned))
11501186 pinnedHeaderAdded[0] = true
11511187 }
11521188 return segments.last().second
@@ -1162,7 +1198,7 @@ private fun buildGroupedSegments(
11621198 val group = sessionDateGroup(node.session, now)
11631199 if (dateHeaderAdded[group] != true) {
11641200 dateHeaderAdded[group] = true
1165− ensureSegment(SessionListEntry.Header("__date_$group", dateGroupLabel(group)))
1201+ ensureSegment(SessionListEntry.Header("__date_$group", dateGroupLabel(group, labels)))
11661202 }
11671203 segments.last().second.add(SessionListEntry.Node(node))
11681204 } else {
@@ -1177,23 +1213,6 @@ private fun buildGroupedSegments(
11771213 return segments
11781214}
11791215
1180−/** Resolve a date-group key to its display label. */
1181−private fun dateGroupLabel(group: String): String = when (group) {
1182− "today" -> todayLabel
1183− "yesterday" -> yesterdayLabel
1184− "last_week" -> lastWeekLabel
1185− else -> olderLabel
1186−}
1187−
1188−// Cached labels resolved lazily from resources at first use. Resolved in the composable
1189−// scope via stringResource and captured here so buildGroupedEntries (a plain function) can
1190−// use them without a @Composable context.
1191−private lateinit var pinnedLabel: String
1192−private lateinit var todayLabel: String
1193−private lateinit var yesterdayLabel: String
1194−private lateinit var lastWeekLabel: String
1195−private lateinit var olderLabel: String
1196−
11971216/** A date-group section header in the session list. Rendered as a muted titleSmall label
11981217 * with heading semantics so TalkBack users can skip between groups. */
11991218@Composable
@@ -1714,7 +1733,12 @@ private fun SessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snackbar
17141733 val result = snackbar.showSnackbar(
17151734 message = message,
17161735 actionLabel = undoLabel,
1717− duration = androidx.compose.material3.SnackbarDuration.Short,
1736+ // Long (~10s) so an accidental swipe has a reasonable window to be undone.
1737+ // The delete undo uses Indefinite with a 5s timed dismiss; Long is a touch
1738+ // longer but still auto-dismisses, which is appropriate for the easily
1739+ // reversible pin/archive actions (vs delete which is destructive enough to
1740+ // warrant Indefinite until tapped or timed out).
1741+ duration = androidx.compose.material3.SnackbarDuration.Long,
17181742 )
17191743 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
17201744 vm.undoSessionAction(event)
@@ -1740,7 +1764,7 @@ private fun BulkSessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snac
17401764 val result = snackbar.showSnackbar(
17411765 message = message,
17421766 actionLabel = undoLabel,
1743− duration = androidx.compose.material3.SnackbarDuration.Short,
1767+ duration = androidx.compose.material3.SnackbarDuration.Long,
17441768 )
17451769 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
17461770 vm.undoBulkAction(event)
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+20
@@ -154,6 +154,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
154154 initialValue = EventStreamClient.ConnectionState.Disconnected,
155155 )
156156
157+ /** SSE reconnect-attempt count, surfaced so the banner can show "Reconnecting (attempt N)…". */
158+ @OptIn(ExperimentalCoroutinesApi::class)
159+ val reconnectAttempts: StateFlow<Int> =
160+ container.activeConnection
161+ .flatMapLatest { it?.events?.reconnectAttempts ?: flowOf(0) }
162+ .stateIn(
163+ scope = viewModelScope,
164+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
165+ initialValue = 0,
166+ )
167+
157168 /** Sessions with activity that arrived while not being viewed, mapped to their
158169 * unread event count so the list can show "N unread" instead of a bare dot. */
159170 val unread: StateFlow<Map<String, Int>> = container.unread
@@ -875,6 +886,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
875886 _transientInfo.tryEmit(container.string(R.string.bulk_marked_read, ids.size))
876887 }
877888
889+ /** Mark every unread session as read at once — the overflow menu's "Mark all read" action.
890+ * Emits a transient confirmation matching [bulkMarkRead], so the action gives feedback
891+ * (and isn't mistaken for a glitch) rather than silently clearing all badges. */
892+ fun markAllRead(unreadIds: Set<String>) {
893+ if (unreadIds.isEmpty()) return
894+ container.clearAllUnread()
895+ _transientInfo.tryEmit(container.string(R.string.bulk_marked_read, unreadIds.size))
896+ }
897+
878898 /** Mark a single session as unread (re-badges it in the list). */
879899 fun markUnread(id: String) {
880900 container.markSessionUnread(id)
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+25−11
@@ -50,6 +50,7 @@ fun TwoPaneSessionChat(
5050 onDisconnect: () -> Unit,
5151 onAddServer: () -> Unit,
5252 onOpenSearch: () -> Unit = {},
53+ onEditProfile: ((String) -> Unit)? = null,
5354 modifier: Modifier = Modifier,
5455) {
5556 var selected by rememberSaveable { mutableStateOf<String?>(null) }
@@ -77,16 +78,30 @@ fun TwoPaneSessionChat(
7778 }
7879 }
7980
81+ // Auto-select latch: runs at most once per Activity instance (saveable so an explicit
82+ // "clear detail pane" via BackHandler survives a rotation — otherwise rotation resets the
83+ // latch, re-arms the effect, and re-selects a session the user just cleared). Reset to
84+ // false on a server switch (see the profile-change effect below) so the new server's top
85+ // session auto-fills the detail pane instead of leaving it empty.
86+ var autoSelected by rememberSaveable { mutableStateOf(false) }
87+
8088 // Clear the selection when the active server profile changes (a server switch via
8189 // the ServerSwitcherMenu). Without this, `selected` still points at the old
8290 // server's session id and the detail pane renders a ChatScreen for a session that
8391 // doesn't exist on the new server, showing a load error with no explanation.
8492 // Skips the initial transition (null -> a profile id) so a deep link / pending
8593 // open session that sets `selected` on the same first composition isn't clobbered.
94+ // Also resets `autoSelected` so the new server's top session auto-fills the detail
95+ // pane — without that, a server switch leaves the pane empty ("Select a session")
96+ // even though the new server has sessions, because the latch stayed `true` from the
97+ // previous server.
8698 var lastProfileId by remember { mutableStateOf<String?>(null) }
8799 LaunchedEffect(activeConnection?.profile?.id) {
88100 val currentId = activeConnection?.profile?.id
89− if (lastProfileId != null && lastProfileId != currentId) selected = null
101+ if (lastProfileId != null && lastProfileId != currentId) {
102+ selected = null
103+ autoSelected = false
104+ }
90105 lastProfileId = currentId
91106 }
92107
@@ -101,16 +116,13 @@ fun TwoPaneSessionChat(
101116
102117 // Auto-select the top of the sorted/filtered list into the detail pane so a wide screen
103118 // doesn't open on a blank right pane. Runs at most once per Activity instance (the
104− // `autoSelected` latch, saveable so an explicit "clear detail pane" via BackHandler
105− // survives a rotation — otherwise rotation resets the latch, re-arms the effect, and
106− // re-selects a session the user just cleared), and only once the first list load has
107− // completed. It defers to any explicit selection already present — a restored `selected`,
108− // a deep-link/notification (pendingOpenSession), or a user pick — and, since the latch
119+ // `autoSelected` latch above), and only once the first list load has completed. It
120+ // defers to any explicit selection already present — a restored `selected`, a
121+ // deep-link/notification (pendingOpenSession), or a user pick — and, since the latch
109122 // is set the moment any of those wins, it never re-selects after a user clears the pane
110− // (BackHandler) or a server switch nulls `selected`. Using state.filtered (not a raw
111− // recency sort) means the auto-selected detail matches the top list row under the
112− // active sort mode, pinned float, archived-hidden, and directory filter.
113− var autoSelected by rememberSaveable { mutableStateOf(false) }
123+ // (BackHandler) or a server switch nulls `selected` (and resets the latch). Using
124+ // state.filtered (not a raw recency sort) means the auto-selected detail matches the top
125+ // list row under the active sort mode, pinned float, archived-hidden, and directory filter.
114126 val autoSelectCandidate = sessionListState.filtered.firstOrNull()?.id
115127 LaunchedEffect(
116128 sessionListState.loading,
@@ -145,7 +157,7 @@ fun TwoPaneSessionChat(
145157 Box(
146158 modifier = Modifier
147159 .weight(NetworkConfig.twoPaneLeftWeight)
148− .widthIn(max = 460.dp),
160+ .widthIn(max = NetworkConfig.twoPaneLeftMaxWidthDp.dp),
149161 ) {
150162 SessionListScreen(
151163 container = container,
@@ -157,6 +169,7 @@ fun TwoPaneSessionChat(
157169 onOpenSettings = onOpenSettings,
158170 onAddServer = onAddServer,
159171 onOpenSearch = onOpenSearch,
172+ onEditProfile = onEditProfile,
160173 externalNewSessionTrigger = newSessionTrigger,
161174 selectedSessionId = selected,
162175 )
@@ -189,6 +202,7 @@ fun TwoPaneSessionChat(
189202 onBack = { selected = null },
190203 onOpenFile = onOpenFile,
191204 onOpenSession = { id -> selected = id },
205+ onEditProfile = onEditProfile,
192206 )
193207 }
194208 }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+27−4
@@ -292,11 +292,32 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
292292 )
293293 } else {
294294 val filtered = remember(reports, crashQuery) { filterCrashReports(reports, crashQuery) }
295− LazyColumn(modifier = Modifier.fillMaxSize()) {
295+ // Windowed render: compose only the first `renderCap` rows, growing as the
296+ // user scrolls near the bottom. Bounds composition work on a device that has
297+ // accumulated many crash reports (e.g. a flaky build used for weeks), matching
298+ // the session/file lists' pattern.
299+ var renderCap by remember {
300+ mutableStateOf(NetworkConfig.diagnosticsListInitialPage)
301+ }
302+ val listState = androidx.compose.foundation.lazy.rememberLazyListState()
303+ androidx.compose.runtime.LaunchedEffect(listState, filtered.size) {
304+ androidx.compose.runtime.snapshotFlow {
305+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
306+ lastVisible >= renderCap - NetworkConfig.diagnosticsListPageStep / 2
307+ }.collect { nearEnd ->
308+ if (nearEnd && renderCap < filtered.size) {
309+ renderCap = (renderCap + NetworkConfig.diagnosticsListPageStep).coerceAtMost(filtered.size)
310+ }
311+ }
312+ }
313+ val capped = remember(filtered, renderCap) {
314+ if (filtered.size <= renderCap) filtered else filtered.take(renderCap)
315+ }
316+ LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
296317 crashSearchHeader(reports = reports, filtered = filtered, crashQuery = crashQuery) {
297318 crashQuery = it
298319 }
299− items(filtered, key = { it.fileName }) { report ->
320+ items(capped, key = { it.fileName }) { report ->
300321 // rememberSaveable so an open row menu survives a rotation; BackHandler
301322 // so system back closes it instead of navigating away, matching the
302323 // session/server/file list dropdown back-handling.
@@ -393,7 +414,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
393414 IconButton(onClick = { menuExpanded = true }) {
394415 Icon(
395416 Icons.Filled.MoreVert,
396− contentDescription = stringResource(R.string.more_options),
417+ contentDescription = stringResource(R.string.more_options_for, report.fileName),
397418 )
398419 }
399420 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
@@ -489,7 +510,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
489510 IconButton(onClick = {
490511 // Confirm before deleting a single report, matching clear-all.
491512 pendingReportDelete = reportName
492− }) { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) }
513+ }, enabled = content != null) {
514+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
515+ }
493516 },
494517 )
495518 },
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+18
@@ -110,6 +110,7 @@ import soy.iko.opencode.di.AppContainer
110110import soy.iko.opencode.R
111111import soy.iko.opencode.data.network.NetworkConfig
112112import soy.iko.opencode.ui.canAuthenticateForAppLock
113+import soy.iko.opencode.ui.components.ConnectionBanner
113114import soy.iko.opencode.ui.theme.LightPaletteSwatches
114115import soy.iko.opencode.ui.theme.DarkPaletteSwatches
115116import soy.iko.opencode.ui.theme.AmoledPaletteSwatches
@@ -184,6 +185,13 @@ fun SettingsScreen(
184185 }
185186 val connectionState by connectionStateFlow
186187 .collectAsStateWithLifecycle(initialValue = EventStreamClient.ConnectionState.Disconnected)
188+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
189+ // Reconnect-attempt count for the banner's "Reconnecting (attempt N)…" label.
190+ @OptIn(ExperimentalCoroutinesApi::class)
191+ val reconnectAttempts by remember {
192+ container.activeConnection
193+ .flatMapLatest { it?.events?.reconnectAttempts ?: kotlinx.coroutines.flow.flowOf(0) }
194+ }.collectAsStateWithLifecycle(initialValue = 0)
187195 val context = LocalContext.current
188196 // Crash count badge: surface that there are reports to look at without making the
189197 // user open the screen to find out.
@@ -292,6 +300,16 @@ fun SettingsScreen(
292300 .nestedScroll(scrollBehavior.nestedScrollConnection)
293301 .padding(16.dp),
294302 ) {
303+ // Prominent connection banner at the top (matching every other content screen)
304+ // so a user who opens Settings during a connection failure sees it immediately
305+ // instead of having to scroll to the Connection section. The inline Connection
306+ // section below remains as a detailed view.
307+ ConnectionBanner(
308+ state = connectionState,
309+ isOnline = isOnline,
310+ onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
311+ reconnectAttempts = reconnectAttempts,
312+ )
295313 val s = settings
296314 // Resolve section-header strings once so the filter logic below can match
297315 // against them without re-resolving per recomposition. These mirror the
app/src/main/res/values-es/strings.xml+18
@@ -46,6 +46,24 @@
4646 <string name="not_connected">Sin conexión.</string>
4747 <string name="no_servers_to_reconnect">No hay ningún servidor guardado al que reconectar.</string>
4848 <string name="retry_now">Reintentar ahora</string>
49+ <string name="edit_credentials">Editar credenciales</string>
50+ <string name="reconnect_attempt">Reconectando (intento %1$d)…</string>
51+ <string name="connection_lost">Se perdió la conexión con "%1$s"</string>
52+ <string name="connection_lost_tap">Toca para reconectar</string>
53+ <string name="message_not_delivered">Mensaje no entregado</string>
54+ <string name="message_not_delivered_body">No se pudo entregar tu respuesta a "%1$s" — toca para abrir y reintentar.</string>
55+ <string name="server_requires_auth">Este servidor requiere un nombre de usuario y contraseña</string>
56+ <string name="credentials_incorrect">El nombre de usuario o la contraseña son incorrectos</string>
57+ <string name="step_summary_collapsed">Resumen del paso</string>
58+ <string name="retrying_ellipsis">Reintentando…</string>
59+ <string name="open_full_editor_hint">El borrador es largo — toca expandir para el editor a pantalla completa</string>
60+ <string name="cannot_quote_this">No se puede citar un mensaje de solo imagen o herramienta</string>
61+ <string name="attachment_name">Nombre del adjunto</string>
62+ <string name="default_model_set">Modelo predeterminado establecido a "%1$s"</string>
63+ <string name="default_model_cleared">Modelo predeterminado borrado</string>
64+ <string name="permission_auto_rejected">Ejecución pausada: permiso rechazado automáticamente tras 10 min</string>
65+ <string name="couldnt_stop_run">No se pudo detener la ejecución — abre la app</string>
66+ <string name="run_stopped_undo">Detenido</string>
4967
5068 <!-- Server list -->
5169 <string name="servers_title">Servidores de opencode</string>
app/src/main/res/values/strings.xml+18
@@ -47,6 +47,24 @@
4747 <string name="not_connected">Not connected.</string>
4848 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
4949 <string name="retry_now">Retry now</string>
50+ <string name="edit_credentials">Edit credentials</string>
51+ <string name="reconnect_attempt">Reconnecting (attempt %1$d)…</string>
52+ <string name="connection_lost">Connection to "%1$s" lost</string>
53+ <string name="connection_lost_tap">Tap to reconnect</string>
54+ <string name="message_not_delivered">Message not delivered</string>
55+ <string name="message_not_delivered_body">Couldn\'t deliver your reply to "%1$s" — tap to open and retry.</string>
56+ <string name="server_requires_auth">This server requires a username and password</string>
57+ <string name="credentials_incorrect">Username or password is incorrect</string>
58+ <string name="step_summary_collapsed">Step summary</string>
59+ <string name="retrying_ellipsis">Retrying…</string>
60+ <string name="open_full_editor_hint">Draft is long — tap expand for the full-screen editor</string>
61+ <string name="cannot_quote_this">Can\'t quote an image or tool-only message</string>
62+ <string name="attachment_name">Attachment name</string>
63+ <string name="default_model_set">Default model set to "%1$s"</string>
64+ <string name="default_model_cleared">Default model cleared</string>
65+ <string name="permission_auto_rejected">Run paused: permission auto-rejected after 10 min</string>
66+ <string name="couldnt_stop_run">Couldn\'t stop run — open app</string>
67+ <string name="run_stopped_undo">Stopped</string>
5068
5169 <!-- Server list -->
5270 <string name="servers_title">opencode servers</string>
app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+40−2
@@ -5,6 +5,7 @@ package soy.iko.opencode.ui.chat
55import kotlinx.coroutines.Dispatchers
66import kotlinx.coroutines.ExperimentalCoroutinesApi
77import kotlinx.coroutines.launch
8+import kotlinx.coroutines.flow.first
89import kotlinx.coroutines.flow.toList
910import kotlinx.coroutines.test.StandardTestDispatcher
1011import kotlinx.coroutines.test.TestScope
@@ -225,6 +226,34 @@ class ChatViewModelTest {
225226 assertFalse(vm.running.value)
226227 }
227228
229+ @Test
230+ fun abort_emitsStopUndoWithLastPrompt() = testScope.runTest {
231+ val container = makeContainer()
232+ val vm = makeVm(container)
233+ assertTrue(vm.send("hello there"))
234+ testScope.testScheduler.advanceUntilIdle()
235+ vm.abort()
236+ testScope.testScheduler.advanceUntilIdle()
237+ // The undo event carries the last-sent prompt so the UI can show "Stopped — Undo".
238+ val prompt = vm.stopUndoEvents.first()
239+ assertEquals("hello there", prompt)
240+ }
241+
242+ @Test
243+ fun resendLastPrompt_reSendsTheTrackedPrompt() = testScope.runTest {
244+ val container = makeContainer()
245+ val vm = makeVm(container)
246+ assertTrue(vm.send("original prompt"))
247+ testScope.testScheduler.advanceUntilIdle()
248+ // Abort surfaces the undo event; resendLastPrompt re-sends the tracked text.
249+ vm.abort()
250+ testScope.testScheduler.advanceUntilIdle()
251+ vm.resendLastPrompt()
252+ testScope.testScheduler.advanceUntilIdle()
253+ // The send should have re-queued the prompt (running re-lights).
254+ assertTrue(vm.running.value)
255+ }
256+
228257 // --- respondPermission() ---
229258
230259 @Test
@@ -334,7 +363,7 @@ class ChatViewModelTest {
334363 // --- Connection state ---
335364
336365 @Test
337− fun sseDisconnected_resetsRunning() = testScope.runTest {
366+ fun sseDisconnected_keepsRunningAndFlagsInterrupted() = testScope.runTest {
338367 val events = FakeEventStreamClient()
339368 val container = makeContainer(events = events)
340369 val vm = makeVm(container)
@@ -342,7 +371,16 @@ class ChatViewModelTest {
342371 testScope.testScheduler.advanceUntilIdle()
343372 events.fakeState.value = EventStreamClient.ConnectionState.Disconnected
344373 testScope.testScheduler.advanceUntilIdle()
345− assertFalse(vm.running.value)
374+ // A transient SSE drop does NOT clear _running — the run is still active on the
375+ // server, and showing it as finished would mislead the user. Instead the stream-
376+ // interrupted flag is set so the working row shows a "reconnecting" visual.
377+ assertTrue(vm.running.value)
378+ assertTrue(vm.streamInterrupted.value)
379+ // Reconnecting clears the interrupted flag but keeps running.
380+ events.fakeState.value = EventStreamClient.ConnectionState.Connected
381+ testScope.testScheduler.advanceUntilIdle()
382+ assertTrue(vm.running.value)
383+ assertFalse(vm.streamInterrupted.value)
346384 }
347385
348386 @Test