git.iko.soy/opencode-android
on master help sign in
Implement 43 UI/UX improvements across chat, sessions, server, files, settings, and notifications
3bab95diko
app/detekt-baseline.xml+5−6
@@ -6,29 +6,28 @@
6 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
6 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
7 <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
7 <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
8 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
8 <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>
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>
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>
11 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
10 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
12 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString</ID>
11 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString</ID>
13 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
12 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
14 <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
13 <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
15 <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
14 <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
16 <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
15 <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>
18 <ID>CyclomaticComplexMethod:FileViewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewScreen( container: AppContainer, path: String, onBack: () -&gt; Unit, initialLine: Int? = null, )</ID>
16 <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>
19 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
18 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
20 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
19 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
21 <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>
20 <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>
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>
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>
25 <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>
22 <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>
26 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
23 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
24+ <ID>LargeClass:SessionListViewModel.kt$SessionListViewModel : ViewModel</ID>
27 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
25 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
28 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
26 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
29 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
27 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
30 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
28 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
31 <ID>TooManyFunctions:CrashLogger.kt$CrashLogger</ID>
29 <ID>TooManyFunctions:CrashLogger.kt$CrashLogger</ID>
30+ <ID>TooManyFunctions:MessageBubble.kt$soy.iko.opencode.ui.chat.MessageBubble.kt</ID>
32 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
31 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
33 <ID>TooManyFunctions:SessionNotifications.kt$SessionNotifications</ID>
32 <ID>TooManyFunctions:SessionNotifications.kt$SessionNotifications</ID>
34 </CurrentIssues>
33 </CurrentIssues>
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17
@@ -55,6 +55,14 @@ open class EventStreamClient(
55 private val _state = MutableStateFlow(ConnectionState.Disconnected)
55 private val _state = MutableStateFlow(ConnectionState.Disconnected)
56 open val state: StateFlow<ConnectionState> = _state.asStateFlow()
56 open val state: StateFlow<ConnectionState> = _state.asStateFlow()
57
57
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+
58 open val events: SharedFlow<BusEvent> by lazy {
66 open val events: SharedFlow<BusEvent> by lazy {
59 stream()
67 stream()
60 .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
68 .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
@@ -166,7 +174,13 @@ open class EventStreamClient(
166 // stopped the upstream while triggerReconnect() queued a signal).
174 // stopped the upstream while triggerReconnect() queued a signal).
167 while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
175 while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
168 var backoffMs = initialBackoffMs
176 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
169 while (isActive) {
180 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++
170 _state.value = ConnectionState.Connecting
184 _state.value = ConnectionState.Connecting
171 // Set by readSseEvents when a triggerReconnect() signal interrupted an active
185 // Set by readSseEvents when a triggerReconnect() signal interrupted an active
172 // read. The signal is consumed to drop the socket, so the backoff select below
186 // read. The signal is consumed to drop the socket, so the backoff select below
@@ -241,6 +255,9 @@ open class EventStreamClient(
241 if (!receivedAny) {
255 if (!receivedAny) {
242 receivedAny = true
256 receivedAny = true
243 backoffMs = initialBackoffMs
257 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
244 }
261 }
245 send(it)
262 send(it)
246 }
263 }
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+50−2
@@ -35,6 +35,10 @@ object NetworkConfig {
35 const val sseInitialBackoffMs = 500L
35 const val sseInitialBackoffMs = 500L
36 /** Cap on the SSE reconnect backoff. */
36 /** Cap on the SSE reconnect backoff. */
37 const val sseMaxBackoffMs = 10_000L
37 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
38 /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
42 /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
39 const val sseIdleTimeoutMs = 90_000L
43 const val sseIdleTimeoutMs = 90_000L
40 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
44 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
@@ -209,8 +213,13 @@ object NetworkConfig {
209 * run-activity changes. A run that starts and then fails/idles almost immediately
213 * run-activity changes. A run that starts and then fails/idles almost immediately
210 * would otherwise dispatch startForegroundService() then stopService() back-to-back;
214 * would otherwise dispatch startForegroundService() then stopService() back-to-back;
211 * if the stop wins the race before startForeground() runs, Android raises the
215 * if the stop wins the race before startForeground() runs, Android raises the
212− * "did not then call startForeground()" crash. Debouncing collapses that window. */
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. */
213− const val runForegroundDebounceMs = 500L
222+ const val runForegroundDebounceMs = 3_000L
214
223
215 // --- Markdown rendering (MarkdownText) ---
224 // --- Markdown rendering (MarkdownText) ---
216
225
@@ -249,6 +258,14 @@ object NetworkConfig {
249 /** Minimum number of crash reports before the diagnostics list shows its search field. */
258 /** Minimum number of crash reports before the diagnostics list shows its search field. */
250 const val diagnosticsSearchThreshold = 8
259 const val diagnosticsSearchThreshold = 8
251
260
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+
252 /** Minimum number of sessions in the usage report before the per-session list shows its
269 /** Minimum number of sessions in the usage report before the per-session list shows its
253 * filter field. A short history is faster to scan than type into. */
270 * filter field. A short history is faster to scan than type into. */
254 const val usageSessionSearchThreshold = 8
271 const val usageSessionSearchThreshold = 8
@@ -271,6 +288,11 @@ object NetworkConfig {
271 /** Right pane weight in the two-pane layout (chat detail). */
288 /** Right pane weight in the two-pane layout (chat detail). */
272 const val twoPaneRightWeight = 0.62f
289 const val twoPaneRightWeight = 0.62f
273
290
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+
274 /** Minimum window width (dp) at which a NavigationRail is shown alongside the
296 /** Minimum window width (dp) at which a NavigationRail is shown alongside the
275 * two-pane layout for top-level destination discoverability on large screens. Sits below
297 * two-pane layout for top-level destination discoverability on large screens. Sits below
276 * [twoPaneWidthThresholdDp] (M3's WindowWidthSizeClass.Medium breakpoint ~600dp) so a
298 * [twoPaneWidthThresholdDp] (M3's WindowWidthSizeClass.Medium breakpoint ~600dp) so a
@@ -321,6 +343,25 @@ object NetworkConfig {
321 /** Lines of tool output rendered before collapsing to a head with a "show more" affordance. */
343 /** Lines of tool output rendered before collapsing to a head with a "show more" affordance. */
322 const val toolOutputCollapsedLimitChars = 4000
344 const val toolOutputCollapsedLimitChars = 4000
323
345
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+
324 /** Characters of tool output included in a shared/exported transcript before truncation. */
365 /** Characters of tool output included in a shared/exported transcript before truncation. */
325 const val exportToolOutputLimitChars = 2000
366 const val exportToolOutputLimitChars = 2000
326
367
@@ -339,6 +380,13 @@ object NetworkConfig {
339 /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
380 /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
340 const val streamingCaretPeriodMs = 500
381 const val streamingCaretPeriodMs = 500
341
382
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+
342 /** Auto-reject a tool-permission prompt after this many ms if the user hasn't responded,
390 /** Auto-reject a tool-permission prompt after this many ms if the user hasn't responded,
343 * so a forgotten prompt doesn't block the run indefinitely. The foreground service keeps
391 * so a forgotten prompt doesn't block the run indefinitely. The foreground service keeps
344 * the process alive, so without a timeout a prompt that the user walked away from holds
392 * 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(
459 }
459 }
460 }
460 }
461
461
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+
462 /**
503 /**
463 * Reconcile active-run tracking on an SSE *reconnect*. A run that completes during a stream
504 * Reconcile active-run tracking on an SSE *reconnect*. A run that completes during a stream
464 * outage never delivers its `SessionIdle`, so its id would stay in [activeRuns] and pin
505 * outage never delivers its `SessionIdle`, so its id would stay in [activeRuns] and pin
@@ -563,18 +604,16 @@ open class AppContainer private constructor(
563 "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status): ${string(R.string.outbox_dropped_text)}",
604 "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status): ${string(R.string.outbox_dropped_text)}",
564 )
605 )
565 outboxStore.remove(msg.id)
606 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.
607+ // Surface the dropped reply on the ERROR channel with a distinct title
568− // postError is the only public entry point that posts there; it renders a
608+ // ("Message not delivered") so the user understands a queued reply
569− // fixed title/text and interpolates its title arg, so the localized
609+ // couldn't be delivered — not that a run failed (which postError
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.
610+ // implies). Tap routes back to the originating profile's session.
572 val ctx = appContext
611 val ctx = appContext
573 if (ctx != null) {
612 if (ctx != null) {
574 runCatchingCancellable {
613 runCatchingCancellable {
575− SessionNotifications.postError(
614+ SessionNotifications.postOutboxDropped(
576 ctx, msg.sessionId,
615 ctx, msg.sessionId,
577− string(R.string.outbox_dropped_title),
616+ msg.sessionId,
578 conn.profile.id,
617 conn.profile.id,
579 )
618 )
580 }.onFailure {
619 }.onFailure {
@@ -1416,6 +1455,7 @@ open class AppContainer private constructor(
1416 observeMessageActivity()
1455 observeMessageActivity()
1417 observeRunReconcileOnReconnect()
1456 observeRunReconcileOnReconnect()
1418 observeRunForegroundService()
1457 observeRunForegroundService()
1458+ observeBackgroundedConnectionFailures()
1419 // Load the persisted muted-sessions set so isSessionMuted() reflects the choice
1459 // Load the persisted muted-sessions set so isSessionMuted() reflects the choice
1420 // immediately on cold start (before any session list composition).
1460 // immediately on cold start (before any session list composition).
1421 appScope.launch {
1461 appScope.launch {
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+36−16
@@ -5,6 +5,7 @@ import android.content.Context
5import android.content.Intent
5import android.content.Intent
6import androidx.core.app.RemoteInput
6import androidx.core.app.RemoteInput
7import soy.iko.opencode.OpencodeApp
7import soy.iko.opencode.OpencodeApp
8+import soy.iko.opencode.R
8import soy.iko.opencode.data.model.PermissionResponse
9import soy.iko.opencode.data.model.PermissionResponse
9import soy.iko.opencode.data.network.NetworkConfig
10import soy.iko.opencode.data.network.NetworkConfig
10import java.util.concurrent.atomic.AtomicBoolean
11import java.util.concurrent.atomic.AtomicBoolean
@@ -32,22 +33,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
32 override fun onReceive(context: Context, intent: Intent) {
33 override fun onReceive(context: Context, intent: Intent) {
33 val container = (context.applicationContext as? OpencodeApp)?.container ?: return
34 val container = (context.applicationContext as? OpencodeApp)?.container ?: return
34 if (intent.action == ACTION_STOP_RUN) {
35 if (intent.action == ACTION_STOP_RUN) {
35− // Stop action from the foreground-service notification. The session id may be
36+ handleStopRun(context, container, intent)
36− // absent if multiple runs were active (generic notification); in that case we
37− // can't target a specific run — just let it ride.
38− val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
39− val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
40− val pending = goAsync()
41− val finished = AtomicBoolean(false)
42− var watchdogThread: Thread? = null
43− val finishOnce = {
44− if (finished.compareAndSet(false, true)) {
45− watchdogThread?.interrupt()
46− pending.finish()
47− }
48− }
49− watchdogThread = watchdog(finishOnce)
50− container.abortRunFromNotification(sessionId, profileId) { _ -> finishOnce() }
51 return
37 return
52 }
38 }
53 val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
39 val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
@@ -145,6 +131,40 @@ class NotificationActionReceiver : BroadcastReceiver() {
145 }
131 }
146 }
132 }
147
133
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+
148 /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
168 /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
149 * the network call hasn't resolved, so the system can't ANR the receiver. The
169 * the network call hasn't resolved, so the system can't ANR the receiver. The
150 * underlying work continues on the app scope. Returns the watchdog thread so the
170 * 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 {
335 .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
335 .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
336 }
336 }
337
337
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+
338 /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
400 /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
339 private fun mutableFlag(): Int =
401 private fun mutableFlag(): Int =
340 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
402 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) {
400 navController.popBackStack(Routes.SERVERS, inclusive = false)
400 navController.popBackStack(Routes.SERVERS, inclusive = false)
401 },
401 },
402 onAddServer = { navController.navigate(Routes.serverEdit()) },
402 onAddServer = { navController.navigate(Routes.serverEdit()) },
403+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
403 )
404 )
404 } else {
405 } else {
405 SessionListScreen(
406 SessionListScreen(
@@ -422,6 +423,7 @@ fun OpencodeApp(container: AppContainer) {
422 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
423 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
423 onOpenSearch = { navController.navigate(Routes.SEARCH) },
424 onOpenSearch = { navController.navigate(Routes.SEARCH) },
424 onAddServer = { navController.navigate(Routes.serverEdit()) },
425 onAddServer = { navController.navigate(Routes.serverEdit()) },
426+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
425 )
427 )
426 }
428 }
427 }
429 }
@@ -447,6 +449,7 @@ fun OpencodeApp(container: AppContainer) {
447 launchSingleTop = true
449 launchSingleTop = true
448 }
450 }
449 },
451 },
452+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
450 )
453 )
451 }
454 }
452
455
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+6
@@ -51,6 +51,7 @@ import androidx.compose.ui.semantics.selectableGroup
51import androidx.compose.ui.semantics.selected
51import androidx.compose.ui.semantics.selected
52import androidx.compose.ui.semantics.semantics
52import androidx.compose.ui.semantics.semantics
53import androidx.compose.ui.text.input.ImeAction
53import androidx.compose.ui.text.input.ImeAction
54+import androidx.compose.ui.text.style.TextOverflow
54import androidx.compose.ui.unit.dp
55import androidx.compose.ui.unit.dp
55import androidx.compose.foundation.layout.Box
56import androidx.compose.foundation.layout.Box
56import kotlinx.coroutines.launch
57import kotlinx.coroutines.launch
@@ -300,6 +301,11 @@ fun AgentPickerSheet(
300 agent.displayDescription,
301 agent.displayDescription,
301 style = MaterialTheme.typography.bodySmall,
302 style = MaterialTheme.typography.bodySmall,
302 color = MaterialTheme.colorScheme.onSurfaceVariant,
303 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,
303 )
309 )
304 }
310 }
305 }
311 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+35−1
@@ -10,11 +10,13 @@ import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.layout.widthIn
11import androidx.compose.foundation.layout.widthIn
12import androidx.compose.foundation.clickable
12import androidx.compose.foundation.clickable
13+import androidx.compose.foundation.combinedClickable
13import androidx.compose.foundation.horizontalScroll
14import androidx.compose.foundation.horizontalScroll
14import androidx.compose.foundation.rememberScrollState
15import androidx.compose.foundation.rememberScrollState
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.Close
17import androidx.compose.material.icons.filled.Close
17import androidx.compose.material.icons.filled.Description
18import androidx.compose.material.icons.filled.Description
19+import androidx.compose.material3.AlertDialog
18import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.CircularProgressIndicator
19import androidx.compose.material3.Icon
21import androidx.compose.material3.Icon
20import androidx.compose.material3.IconButton
22import androidx.compose.material3.IconButton
@@ -153,7 +155,9 @@ private fun StagingChip(stagingFileCount: Int) {
153@Composable
155@Composable
154private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
156private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
155 var showPreview by remember { mutableStateOf(false) }
157 var showPreview by remember { mutableStateOf(false) }
158+ var showNameDialog by remember { mutableStateOf(false) }
156 val previewLabel = stringResource(R.string.preview_attachment)
159 val previewLabel = stringResource(R.string.preview_attachment)
160+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
157 Surface(
161 Surface(
158 shape = MaterialTheme.shapes.small,
162 shape = MaterialTheme.shapes.small,
159 color = MaterialTheme.colorScheme.surfaceVariant,
163 color = MaterialTheme.colorScheme.surfaceVariant,
@@ -172,10 +176,22 @@ private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit)
172 .clickable(role = Role.Image) { showPreview = true },
176 .clickable(role = Role.Image) { showPreview = true },
173 )
177 )
174 } else {
178 } 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.
175 Icon(
182 Icon(
176 Icons.Filled.Description,
183 Icons.Filled.Description,
177 contentDescription = null,
184 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+ ),
179 tint = MaterialTheme.colorScheme.onSurfaceVariant,
195 tint = MaterialTheme.colorScheme.onSurfaceVariant,
180 )
196 )
181 }
197 }
@@ -221,4 +237,22 @@ private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit)
221 }
237 }
222 }
238 }
223 }
239 }
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+ }
224}
258}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+148−42
@@ -203,10 +203,12 @@ fun ChatScreen(
203 onOpenFile: ((String) -> Unit)? = null,
203 onOpenFile: ((String) -> Unit)? = null,
204 onOpenSession: ((String) -> Unit)? = null,
204 onOpenSession: ((String) -> Unit)? = null,
205 focusMessageId: String? = null,
205 focusMessageId: String? = null,
206+ onEditProfile: ((String) -> Unit)? = null,
206) {
207) {
207 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
208 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
208 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
209 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
209 val running by vm.running.collectAsStateWithLifecycle()
210 val running by vm.running.collectAsStateWithLifecycle()
211+ val streamInterrupted by vm.streamInterrupted.collectAsStateWithLifecycle()
210 val aborting by vm.aborting.collectAsStateWithLifecycle()
212 val aborting by vm.aborting.collectAsStateWithLifecycle()
211 val loading by vm.loading.collectAsStateWithLifecycle()
213 val loading by vm.loading.collectAsStateWithLifecycle()
212 val loadError by vm.loadError.collectAsStateWithLifecycle()
214 val loadError by vm.loadError.collectAsStateWithLifecycle()
@@ -217,6 +219,7 @@ fun ChatScreen(
217 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
219 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
218 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
220 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
219 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
221 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
222+ val reconnectAttempts by vm.reconnectAttempts.collectAsStateWithLifecycle()
220 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
223 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
221 val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
224 val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
222 val agents by vm.agents.collectAsStateWithLifecycle()
225 val agents by vm.agents.collectAsStateWithLifecycle()
@@ -296,7 +299,6 @@ fun ChatScreen(
296 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
299 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
297 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
300 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
298 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
301 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
299− var showStopConfirm by rememberSaveable { mutableStateOf(false) }
300 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
302 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
301 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
303 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
302 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
304 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
@@ -363,6 +365,20 @@ fun ChatScreen(
363 }
365 }
364 }
366 }
365
367
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+
366 // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
382 // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
367 // window on the container; show the snackbar over the conversation the user is viewing so
383 // window on the container; show the snackbar over the conversation the user is viewing so
368 // they can cancel without being navigated to the session list first. If the window expires
384 // they can cancel without being navigated to the session list first. If the window expires
@@ -547,8 +563,8 @@ fun ChatScreen(
547 ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
563 ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
548 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
564 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
549 ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
565 ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
550− ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
566+ ev.key == Key.Escape && running && !showExitConfirm -> {
551− showStopConfirm = true; true
567+ vm.abort(); true
552 }
568 }
553 else -> false
569 else -> false
554 }
570 }
@@ -893,7 +909,7 @@ fun ChatScreen(
893 enabled = activeConnection != null,
909 enabled = activeConnection != null,
894 sendOnEnter = sendOnEnter,
910 sendOnEnter = sendOnEnter,
895 onSend = ::doSend,
911 onSend = ::doSend,
896− onAbort = { showStopConfirm = true },
912+ onAbort = { vm.abort() },
897 queuedFollowUp = queuedFollowUp,
913 queuedFollowUp = queuedFollowUp,
898 onQueueFollowUp = vm::queueFollowUp,
914 onQueueFollowUp = vm::queueFollowUp,
899 onCancelQueue = { vm.queueFollowUp("") },
915 onCancelQueue = { vm.queueFollowUp("") },
@@ -1155,7 +1171,21 @@ fun ChatScreen(
1155 // content padding accounts for the bottomBar's raised height, so the message
1171 // content padding accounts for the bottomBar's raised height, so the message
1156 // list is already above the keyboard. Adding imePadding here would double-apply
1172 // list is already above the keyboard. Adding imePadding here would double-apply
1157 // the IME inset and push messages too far up.
1173 // the IME inset and push messages too far up.
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(
1158− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
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)
1159 if (activeConnection == null) {
1189 if (activeConnection == null) {
1160 Column(
1190 Column(
1161 modifier = Modifier.align(Alignment.Center).padding(24.dp),
1191 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -1187,6 +1217,10 @@ fun ChatScreen(
1187 // fires while the banner is composed (visible), so gate the inset on
1217 // fires while the banner is composed (visible), so gate the inset on
1188 // bannerVisible and fall back to 44dp until the first measurement arrives.
1218 // bannerVisible and fall back to 44dp until the first measurement arrives.
1189 var bannerHeightPx by remember { mutableIntStateOf(0) }
1219 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) }
1190 val density = LocalDensity.current
1224 val density = LocalDensity.current
1191 val topPad = if (bannerVisible) {
1225 val topPad = if (bannerVisible) {
1192 if (bannerHeightPx > 0) {
1226 if (bannerHeightPx > 0) {
@@ -1196,17 +1230,29 @@ fun ChatScreen(
1196 }
1230 }
1197 } else {
1231 } else {
1198 16.dp
1232 16.dp
1233+ } + if (searchActive && searchbarHeightPx > 0) {
1234+ with(density) { searchbarHeightPx.toDp() } + 4.dp
1235+ } else {
1236+ 0.dp
1199 }
1237 }
1200 ConnectionBanner(
1238 ConnectionBanner(
1201 state = connectionState,
1239 state = connectionState,
1202 modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1240 modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1203 isOnline = isOnline,
1241 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
1205 // re-seeds from REST). refreshMessages is the right recovery path
1243 // re-seeds from REST). refreshMessages is the right recovery path
1206 // when the connection is present but the stream died; reconnect()
1244 // when the connection is present but the stream died; reconnect()
1207 // is the path when the whole connection is gone (handled by the
1245 // is the path when the whole connection is gone (handled by the
1208 // separate "Not connected" state below).
1246 // separate "Not connected" state below).
1209 onRetry = { vm.refreshMessages() },
1247 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,
1210 )
1256 )
1211 // Persistent inline error banner for a mid-conversation load failure.
1257 // Persistent inline error banner for a mid-conversation load failure.
1212 // Unlike the one-shot snackbar (which fires once per streak then goes
1258 // Unlike the one-shot snackbar (which fires once per streak then goes
@@ -1286,6 +1332,27 @@ fun ChatScreen(
1286 Button(onClick = { vm.refreshMessages() }) {
1332 Button(onClick = { vm.refreshMessages() }) {
1287 Text(stringResource(R.string.retry))
1333 Text(stringResource(R.string.retry))
1288 }
1334 }
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+ }
1289 }
1356 }
1290 } else if (messages.isEmpty() && running) {
1357 } else if (messages.isEmpty() && running) {
1291 // A run just started but no parts have arrived yet — the empty list with
1358 // A run just started but no parts have arrived yet — the empty list with
@@ -1391,8 +1458,10 @@ fun ChatScreen(
1391 }
1458 }
1392 // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1459 // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1393 // snapping back (the message isn't dismissed — the quote fills the composer).
1460 // 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
1461+ // Enabled for non-text messages too (image/tool-only), but the confirm
1395− // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
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)
1396 val swipeState = rememberSwipeToDismissBoxState(
1465 val swipeState = rememberSwipeToDismissBoxState(
1397 confirmValueChange = { value ->
1466 confirmValueChange = { value ->
1398 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1467 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
@@ -1400,10 +1469,18 @@ fun ChatScreen(
1400 } else {
1469 } else {
1401 SwipeToDismissBoxValue.StartToEnd
1470 SwipeToDismissBoxValue.StartToEnd
1402 }
1471 }
1403− if (value == replyValue && quoteText != null) {
1472+ if (value == replyValue) {
1473+ if (quoteText != null) {
1404− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1474+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1405− vm.quoteReply(quoteText)
1475+ vm.quoteReply(quoteText)
1406− runCatching { inputFocusRequester.requestFocus() }
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+ }
1407 }
1484 }
1408 false
1485 false
1409 },
1486 },
@@ -1415,8 +1492,8 @@ fun ChatScreen(
1415 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1492 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1416 SwipeToDismissBox(
1493 SwipeToDismissBox(
1417 state = swipeState,
1494 state = swipeState,
1418− enableDismissFromEndToStart = !replyFromStartToEnd && quoteText != null,
1495+ enableDismissFromEndToStart = !replyFromStartToEnd,
1419− enableDismissFromStartToEnd = replyFromStartToEnd && quoteText != null,
1496+ enableDismissFromStartToEnd = replyFromStartToEnd,
1420 backgroundContent = {
1497 backgroundContent = {
1421 Box(
1498 Box(
1422 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1499 Modifier.fillMaxSize().padding(horizontal = 16.dp),
@@ -1528,6 +1605,11 @@ fun ChatScreen(
1528 if (running) {
1605 if (running) {
1529 item(key = "__typing") {
1606 item(key = "__typing") {
1530 val workingText = stringResource(R.string.working)
1607 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
1531 // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1613 // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1532 // survives LazyColumn recycling — a local remember reset to 0:00 when
1614 // survives LazyColumn recycling — a local remember reset to 0:00 when
1533 // the row was disposed and scrolled back into view. The VM stamps the
1615 // the row was disposed and scrolled back into view. The VM stamps the
@@ -1574,11 +1656,18 @@ fun ChatScreen(
1574 Row(
1656 Row(
1575 verticalAlignment = Alignment.CenterVertically,
1657 verticalAlignment = Alignment.CenterVertically,
1576 modifier = Modifier.semantics(mergeDescendants = true) {
1658 modifier = Modifier.semantics(mergeDescendants = true) {
1577− contentDescription = "$workingText $elapsedText"
1659+ contentDescription = "$interruptedLabel $elapsedText"
1578 },
1660 },
1579 ) {
1661 ) {
1662+ // Dim the spinner when interrupted so the row reads as
1663+ // "paused/reconnecting" rather than actively progressing.
1580− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1664+ CircularProgressIndicator(
1665+ Modifier.size(16.dp),
1666+ strokeWidth = 2.dp,
1667+ color = if (streamInterrupted) MaterialTheme.colorScheme.onSurfaceVariant
1668+ else MaterialTheme.colorScheme.primary,
1669+ )
1581− Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1670+ Text(interruptedLabel, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1582 Spacer(Modifier.size(8.dp))
1671 Spacer(Modifier.size(8.dp))
1583 Icon(
1672 Icon(
1584 Icons.Filled.Schedule,
1673 Icons.Filled.Schedule,
@@ -1600,7 +1689,7 @@ fun ChatScreen(
1600 Spacer(Modifier.weight(1f))
1689 Spacer(Modifier.weight(1f))
1601 val stopLabel = stringResource(R.string.stop)
1690 val stopLabel = stringResource(R.string.stop)
1602 IconButton(
1691 IconButton(
1603− onClick = { showStopConfirm = true },
1692+ onClick = { vm.abort() },
1604 modifier = Modifier.semantics { contentDescription = stopLabel },
1693 modifier = Modifier.semantics { contentDescription = stopLabel },
1605 ) {
1694 ) {
1606 Icon(Icons.Filled.Stop, contentDescription = null)
1695 Icon(Icons.Filled.Stop, contentDescription = null)
@@ -1618,7 +1707,9 @@ fun ChatScreen(
1618 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1707 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1619 enter = fabMotion.enter,
1708 enter = fabMotion.enter,
1620 exit = fabMotion.exit,
1709 exit = fabMotion.exit,
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).
1621− modifier = Modifier.align(Alignment.BottomEnd),
1712+ modifier = Modifier.align(Alignment.BottomEnd).padding(end = sideMargin),
1622 ) {
1713 ) {
1623 // Badge the FAB when new content arrived while the user was scrolled up, so
1714 // Badge the FAB when new content arrived while the user was scrolled up, so
1624 // they know there's something new to jump to (cleared once back at bottom).
1715 // they know there's something new to jump to (cleared once back at bottom).
@@ -1669,7 +1760,7 @@ fun ChatScreen(
1669 visible = scrolledDown && listItems.size > 4,
1760 visible = scrolledDown && listItems.size > 4,
1670 enter = fabMotion.enter,
1761 enter = fabMotion.enter,
1671 exit = fabMotion.exit,
1762 exit = fabMotion.exit,
1672− modifier = Modifier.align(Alignment.BottomStart),
1763+ modifier = Modifier.align(Alignment.BottomStart).padding(start = sideMargin),
1673 ) {
1764 ) {
1674 SmallFloatingActionButton(
1765 SmallFloatingActionButton(
1675 onClick = {
1766 onClick = {
@@ -1690,13 +1781,16 @@ fun ChatScreen(
1690 visible = searchActive,
1781 visible = searchActive,
1691 enter = searchMotion.enter,
1782 enter = searchMotion.enter,
1692 exit = searchMotion.exit,
1783 exit = searchMotion.exit,
1784+ // Inset by sideMargin on wide screens so the search bar spans the capped
1785+ // list's width, not the full screen width.
1693− modifier = Modifier.align(Alignment.TopCenter),
1786+ modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = sideMargin),
1694 ) {
1787 ) {
1695 Surface(
1788 Surface(
1696 tonalElevation = 3.dp,
1789 tonalElevation = 3.dp,
1697 modifier = Modifier
1790 modifier = Modifier
1698 .padding(horizontal = 12.dp, vertical = 4.dp)
1791 .padding(horizontal = 12.dp, vertical = 4.dp)
1699− .fillMaxWidth(),
1792+ .fillMaxWidth()
1793+ .onSizeChanged { searchbarHeightPx = it.height },
1700 ) {
1794 ) {
1701 // Focus the search field when the bar appears (from the overflow menu's
1795 // Focus the search field when the bar appears (from the overflow menu's
1702 // "Find in conversation" or Ctrl+F), so the user can start typing
1796 // "Find in conversation" or Ctrl+F), so the user can start typing
@@ -1728,7 +1822,12 @@ fun ChatScreen(
1728 } else "",
1822 } else "",
1729 style = MaterialTheme.typography.labelSmall,
1823 style = MaterialTheme.typography.labelSmall,
1730 color = MaterialTheme.colorScheme.onSurfaceVariant,
1824 color = MaterialTheme.colorScheme.onSurfaceVariant,
1825+ modifier = Modifier
1731− modifier = Modifier.padding(horizontal = 4.dp),
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 },
1732 )
1831 )
1733 // Step through matches (Previous / Next), disabled when there are
1832 // Step through matches (Previous / Next), disabled when there are
1734 // fewer than two to navigate. Wrap around at the ends so repeated
1833 // fewer than two to navigate. Wrap around at the ends so repeated
@@ -1798,24 +1897,6 @@ fun ChatScreen(
1798 )
1897 )
1799 }
1898 }
1800
1899
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−
1819 if (showSummarizeConfirm) {
1900 if (showSummarizeConfirm) {
1820 AlertDialog(
1901 AlertDialog(
1821 onDismissRequest = { showSummarizeConfirm = false },
1902 onDismissRequest = { showSummarizeConfirm = false },
@@ -1899,6 +1980,10 @@ fun ChatScreen(
1899 container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
1980 container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
1900 }
1981 }
1901 }
1982 }
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")
1902 ModelPickerSheet(
1987 ModelPickerSheet(
1903 options = models,
1988 options = models,
1904 selected = selectedModel,
1989 selected = selectedModel,
@@ -1912,7 +1997,18 @@ fun ChatScreen(
1912 onDismiss = { showModelPicker = false },
1997 onDismiss = { showModelPicker = false },
1913 preferredModelId = preferredModelId,
1998 preferredModelId = preferredModelId,
1914 onSetPreferredModel = { id ->
1999 onSetPreferredModel = { id ->
2000+ scope.launch {
1915− scope.launch { runCatchingCancellable { container.settingsStore.setPreferredModelId(id) } }
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+ }
1916 },
2012 },
1917 recent = recentOptions,
2013 recent = recentOptions,
1918 )
2014 )
@@ -2294,6 +2390,16 @@ private fun ChatInputBar(
2294 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
2390 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
2295 else MaterialTheme.colorScheme.onSurfaceVariant,
2391 else MaterialTheme.colorScheme.onSurfaceVariant,
2296 )
2392 )
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+ )
2297 }
2403 }
2298 },
2404 },
2299 keyboardOptions = KeyboardOptions(
2405 keyboardOptions = KeyboardOptions(
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+83−6
@@ -342,6 +342,17 @@ class ChatViewModel(
342 private val _running = MutableStateFlow(false)
342 private val _running = MutableStateFlow(false)
343 val running: StateFlow<Boolean> = _running.asStateFlow()
343 val running: StateFlow<Boolean> = _running.asStateFlow()
344
344
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+
345 /**
356 /**
346 * Wall-clock millis at which the current run started (0L when no run is active). Drives
357 * Wall-clock millis at which the current run started (0L when no run is active). Drives
347 * the typing row's elapsed timer so it survives LazyColumn recycling — a local `remember`
358 * the typing row's elapsed timer so it survives LazyColumn recycling — a local `remember`
@@ -388,6 +399,17 @@ class ChatViewModel(
388 private val _queuedFollowUp = MutableStateFlow<String?>(null)
399 private val _queuedFollowUp = MutableStateFlow<String?>(null)
389 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
400 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
390
401
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+
391 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
413 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
392 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
414 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
393 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
415 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
@@ -429,6 +451,17 @@ class ChatViewModel(
429 initialValue = EventStreamClient.ConnectionState.Disconnected,
451 initialValue = EventStreamClient.ConnectionState.Disconnected,
430 )
452 )
431
453
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+
432 private val _pendingPermission = MutableStateFlow<Permission?>(null)
465 private val _pendingPermission = MutableStateFlow<Permission?>(null)
433 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
466 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
434
467
@@ -763,6 +796,7 @@ class ChatViewModel(
763 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
796 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
764 // returns with no connection) until a later reconnect happens to clear it.
797 // returns with no connection) until a later reconnect happens to clear it.
765 _running.value = false
798 _running.value = false
799+ _streamInterrupted.value = false
766 _aborting.value = false
800 _aborting.value = false
767 clearPermissions()
801 clearPermissions()
768 sessionAllowed.clear()
802 sessionAllowed.clear()
@@ -771,6 +805,7 @@ class ChatViewModel(
771 return@collectLatest
805 return@collectLatest
772 }
806 }
773 _running.value = false
807 _running.value = false
808+ _streamInterrupted.value = false
774 // Allow the re-light below to recover a still-running run after this (re)connect;
809 // Allow the re-light below to recover a still-running run after this (re)connect;
775 // a run that actually finished during the outage emits no live parts, so this
810 // a run that actually finished during the outage emits no live parts, so this
776 // won't spuriously re-light.
811 // won't spuriously re-light.
@@ -942,6 +977,11 @@ class ChatViewModel(
942 )
977 )
943 // If the SSE stream drops mid-run, the run indicator would spin forever;
978 // If the SSE stream drops mid-run, the run indicator would spin forever;
944 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
979 // 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.
945 viewModelScope.launch {
985 viewModelScope.launch {
946 container.activeConnection.collectLatest { conn ->
986 container.activeConnection.collectLatest { conn ->
947 if (conn == null) return@collectLatest
987 if (conn == null) return@collectLatest
@@ -954,11 +994,28 @@ class ChatViewModel(
954 while (currentCoroutineContext().isActive) {
994 while (currentCoroutineContext().isActive) {
955 try {
995 try {
956 conn.events.state.collect { state ->
996 conn.events.state.collect { state ->
997+ when (state) {
998+ EventStreamClient.ConnectionState.Connected -> {
999+ _streamInterrupted.value = false
1000+ }
957− val isDown = state == EventStreamClient.ConnectionState.Disconnected ||
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+ }
958− state == EventStreamClient.ConnectionState.Failed ||
1007+ EventStreamClient.ConnectionState.Failed,
959− state == EventStreamClient.ConnectionState.AuthFailed
1008+ EventStreamClient.ConnectionState.AuthFailed -> {
960− if (isDown && _running.value) {
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
961− _running.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+ }
962 }
1019 }
963 }
1020 }
964 } catch (e: CancellationException) {
1021 } catch (e: CancellationException) {
@@ -1045,6 +1102,9 @@ class ChatViewModel(
1045 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
1102 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
1046 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
1103 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
1047 }
1104 }
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
1048 viewModelScope.launch {
1108 viewModelScope.launch {
1049 runCatchingCancellable {
1109 runCatchingCancellable {
1050 conn.repository.sendPrompt(
1110 conn.repository.sendPrompt(
@@ -1304,7 +1364,14 @@ class ChatViewModel(
1304 // before the request even reaches the server) and the run keeps burning tokens
1364 // before the request even reaches the server) and the run keeps burning tokens
1305 // server-side despite the user explicitly stopping it.
1365 // server-side despite the user explicitly stopping it.
1306 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1366 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1367+ .onSuccess {
1307− .onSuccess { _running.value = false }
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+ }
1308 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1375 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1309 } finally {
1376 } finally {
1310 _aborting.value = false
1377 _aborting.value = false
@@ -1312,6 +1379,16 @@ class ChatViewModel(
1312 }
1379 }
1313 }
1380 }
1314
1381
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+
1315 fun addAttachment(attachment: PendingAttachment) {
1392 fun addAttachment(attachment: PendingAttachment) {
1316 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1393 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1317 // count caps are checked upstream). The base64 data URLs are held in memory and
1394 // 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(
273 )
273 )
274}
274}
275
275
276−/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session, view source).
276+/** Overflow menu of per-message actions, surfaced as a visible icon button in the footer so
277− * Shown when at least one action is available and there's text to act on. Keeps the
277+ * the full action set is reachable in one tap from a visible affordance — not only via the
278− * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
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. */
279@Composable
282@Composable
280private fun MessageOverflow(
283private fun MessageOverflow(
281 text: String,
284 text: String,
282 onQuote: ((String) -> Unit)?,
285 onQuote: ((String) -> Unit)?,
283 onBranch: ((String) -> Unit)?,
286 onBranch: ((String) -> Unit)?,
284 onViewSource: (() -> Unit)? = null,
287 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,
285) {
295) {
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(
286− if (onQuote == null && onBranch == null && onViewSource == null) return
299+ onQuote, onBranch, onViewSource, onCopy, onEdit, onRegenerate, onRevert, onShare,
300+ ).any { it != null } || (onSpeak != null && speakLabel != null)
301+ if (!hasAnyAction) return
287 var expanded by remember { mutableStateOf(false) }
302 var expanded by remember { mutableStateOf(false) }
288 val moreLabel = stringResource(R.string.message_actions)
303 val moreLabel = stringResource(R.string.message_actions)
289 val haptics = LocalHapticFeedback.current
304 val haptics = LocalHapticFeedback.current
@@ -297,43 +312,145 @@ private fun MessageOverflow(
297 )
312 )
298 }
313 }
299 DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
314 DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
300− onQuote?.let { quote ->
315+ MessageOverflowItems(
301− DropdownMenuItem(
316+ text = text,
302− leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
317+ onCopy = onCopy,
303− text = { Text(stringResource(R.string.quote_reply)) },
318+ onShare = onShare,
304− onClick = {
319+ onEdit = onEdit,
305− expanded = false
320+ onRegenerate = onRegenerate,
306− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
321+ onSpeak = onSpeak,
307− quote(text)
322+ speakLabel = speakLabel,
308− },
323+ onQuote = onQuote,
309− )
324+ onBranch = onBranch,
310− }
325+ onViewSource = onViewSource,
311− onBranch?.let { branch ->
326+ onRevert = onRevert,
312− DropdownMenuItem(
327+ onDismissMenu = { expanded = false },
313− leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null) },
328+ haptics = haptics,
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− )
329+ )
332− }
333 }
330 }
334 }
331 }
335}
332}
336
333
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+
337/** A small "revert to before this message" icon button, shown next to Copy when a revert
454/** A small "revert to before this message" icon button, shown next to Copy when a revert
338 * handler is supplied. Reverting is undoable (see the chat's revert banner), so it acts
455 * handler is supplied. Reverting is undoable (see the chat's revert banner), so it acts
339 * immediately without a confirmation dialog. */
456 * immediately without a confirmation dialog. */
@@ -638,10 +755,28 @@ private fun UserBubble(
638 )
755 )
639 }
756 }
640 }
757 }
641− // Quote-reply / branch-a-new-session, tucked behind an overflow so the
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
642− // inline row stays compact. Only when there's prompt text to act on.
765+ // discoverability. Only when there's prompt text to act on.
643 if (textToCopy != null) {
766 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+ )
645 }
780 }
646 }
781 }
647 }
782 }
@@ -845,6 +980,7 @@ private fun AssistantBlock(
845 onRegenerate = onRegenerate,
980 onRegenerate = onRegenerate,
846 onContinue = onContinue,
981 onContinue = onContinue,
847 onViewSource = textToCopy?.let { { showSource = true } },
982 onViewSource = textToCopy?.let { { showSource = true } },
983+ onShare = onShare,
848 )
984 )
849 }
985 }
850 AssistantMessageLongPressMenu(
986 AssistantMessageLongPressMenu(
@@ -999,6 +1135,7 @@ private fun AssistantActions(
999 onRegenerate: (() -> Unit)? = null,
1135 onRegenerate: (() -> Unit)? = null,
1000 onContinue: (() -> Unit)? = null,
1136 onContinue: (() -> Unit)? = null,
1001 onViewSource: (() -> Unit)? = null,
1137 onViewSource: (() -> Unit)? = null,
1138+ onShare: (() -> Unit)? = null,
1002) {
1139) {
1003 val context = LocalContext.current
1140 val context = LocalContext.current
1004 val haptics = LocalHapticFeedback.current
1141 val haptics = LocalHapticFeedback.current
@@ -1068,11 +1205,47 @@ private fun AssistantActions(
1068 }
1205 }
1069 }
1206 }
1070 // Assistant replies can be quoted into the composer or branched into a new session.
1207 // 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
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
1072− // menu) so the action is discoverable without a long-press. MessageOverflow itself
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
1073 // no-ops when all callbacks are null, so the guard only needs to check text.
1215 // no-ops when all callbacks are null, so the guard only needs to check text.
1074 if (textToCopy != null) {
1216 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+ )
1076 }
1249 }
1077 }
1250 }
1078}
1251}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+71−21
@@ -372,16 +372,20 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
372 modifier = Modifier.padding(start = 6.dp),
372 modifier = Modifier.padding(start = 6.dp),
373 )
373 )
374 // Wall-clock duration of the tool call (e.g. "· 2.3s"), shown when the server
374 // 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
375+ // reports both start and end. Omitted while still running (no end yet), when
376− // timing wasn't reported — a bare "· 0ms" would be noise. Placed inline with the
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).
377− // tool name so a glance at the row tells both *what* and *how long*.
378+ // Placed inline with the tool name so a glance at the row tells both *what* and
379+ // *how long*.
378 part.state.durationMs()?.let { ms ->
380 part.state.durationMs()?.let { ms ->
381+ if (ms >= NetworkConfig.toolDurationHideBelowMs) {
379− Text(
382+ Text(
380− stringResource(R.string.tool_duration_format, formatToolDuration(ms)),
383+ stringResource(R.string.tool_duration_format, formatToolDuration(ms)),
381− style = MaterialTheme.typography.labelSmall,
384+ style = MaterialTheme.typography.labelSmall,
382− color = MaterialTheme.colorScheme.onSurfaceVariant,
385+ color = MaterialTheme.colorScheme.onSurfaceVariant,
383− modifier = Modifier.padding(start = 6.dp),
386+ modifier = Modifier.padding(start = 6.dp),
384− )
387+ )
388+ }
385 }
389 }
386 }
390 }
387 // A human-readable summary of what the tool was asked to do (e.g. "Reading
391 // 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) {
410 val pretty = remember(input) {
414 val pretty = remember(input) {
411 runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
415 runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
412 }
416 }
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
413 CollapsibleDetail(
422 CollapsibleDetail(
414 label = inputLabel,
423 label = inputLabel,
415 detail = pretty,
424 detail = pretty,
416 isDiff = false,
425 isDiff = false,
417 keySuffix = "input",
426 keySuffix = "input",
427+ defaultExpanded = autoExpand,
418 // The input block has a label row whose collapse toggle announces its state
428 // The input block has a label row whose collapse toggle announces its state
419 // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
429 // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
420 // rather than an empty state (the defaults are "").
430 // rather than an empty state (the defaults are "").
@@ -439,6 +449,12 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
439 // looksLikeDiff scans the whole string; memoize on `detail` so a recomposition that
449 // looksLikeDiff scans the whole string; memoize on `detail` so a recomposition that
440 // doesn't change the content (e.g. the expand/collapse flip) doesn't re-scan.
450 // doesn't change the content (e.g. the expand/collapse flip) doesn't re-scan.
441 val isDiff = remember(detail) { looksLikeDiff(detail) }
451 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
442 val collapsed = remember(detail, isDiff) {
458 val collapsed = remember(detail, isDiff) {
443 val head = detail.take(COLLAPSED_LIMIT)
459 val head = detail.take(COLLAPSED_LIMIT)
444 // Truncating a diff mid-line makes DiffView render a malformed final line, so
460 // Truncating a diff mid-line makes DiffView render a malformed final line, so
@@ -448,22 +464,22 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
448 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
464 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
449 val expandedState = stringResource(R.string.state_expanded)
465 val expandedState = stringResource(R.string.state_expanded)
450 val collapsedState = stringResource(R.string.state_collapsed)
466 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
452 // How many lines are hidden while collapsed. detail.lines() splits the whole
468 // How many lines are hidden while collapsed. detail.lines() splits the whole
453 // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
469 // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
454 // every recomposition, including each streaming update of a running tool.
470 // every recomposition, including each streaming update of a running tool.
455 // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
471 // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
456 // 0 (a single long line cut mid-way) falls back to the generic label.
472 // 0 (a single long line cut mid-way) falls back to the generic label.
457− val moreLines = remember(detail, collapsed) {
473+ val moreLines = remember(detail, collapsed, lineCount) {
458− if (detail.length > COLLAPSED_LIMIT) detail.lines().size - collapsed.lines().size else 0
474+ if (exceedsLimit) lineCount - collapsed.lines().size else 0
459 }
475 }
460 CollapsibleDetail(
476 CollapsibleDetail(
461 label = null,
477 label = null,
462 detail = display,
478 detail = display,
463 isDiff = isDiff,
479 isDiff = isDiff,
464 keySuffix = "output",
480 keySuffix = "output",
465− expanded = expanded || detail.length <= COLLAPSED_LIMIT,
481+ expanded = expanded || !exceedsLimit,
466− onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
482+ onToggleExpand = if (exceedsLimit) {
467 { expanded = !expanded }
483 { expanded = !expanded }
468 } else null,
484 } else null,
469 moreLines = moreLines,
485 moreLines = moreLines,
@@ -500,10 +516,13 @@ private fun CollapsibleDetail(
500 onCopy: (() -> Unit)? = null,
516 onCopy: (() -> Unit)? = null,
501 diffSaveKey: String? = null,
517 diffSaveKey: String? = null,
502 isError: Boolean = false,
518 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,
503) {
522) {
504 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
523 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
505 if (label != null) {
524 if (label != null) {
506− var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
525+ var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(defaultExpanded) }
507 Row(
526 Row(
508 modifier = Modifier
527 modifier = Modifier
509 .fillMaxWidth()
528 .fillMaxWidth()
@@ -719,6 +738,10 @@ private fun FileChip(
719 * a main agent + a sub-agent) shows the cost breakdown per step. Compact and muted
738 * a main agent + a sub-agent) shows the cost breakdown per step. Compact and muted
720 * to read as a footnote rather than a primary element. Returns nothing when the
739 * to read as a footnote rather than a primary element. Returns nothing when the
721 * step reported neither cost nor tokens (some steps report no usage).
740 * 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.
722 */
745 */
723@Composable
746@Composable
724private fun StepFinishSummary(part: StepFinishPart) {
747private fun StepFinishSummary(part: StepFinishPart) {
@@ -734,10 +757,37 @@ private fun StepFinishSummary(part: StepFinishPart) {
734 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
757 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
735 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
758 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
736 } ?: return
759 } ?: return
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) {
737− Text(
778+ Text(
738− summary,
779+ summary,
739− style = MaterialTheme.typography.labelSmall,
780+ style = MaterialTheme.typography.labelSmall,
740− color = MaterialTheme.colorScheme.onSurfaceVariant,
781+ color = MaterialTheme.colorScheme.onSurfaceVariant,
741− modifier = Modifier.padding(top = 2.dp),
782+ modifier = Modifier.padding(start = 4.dp, top = 2.dp),
742− )
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+ }
743}
793}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+21−14
@@ -87,9 +87,6 @@ fun PermissionDialog(
87 // indefinitely (the foreground service holds the process alive). LaunchedEffect keyed on
87 // indefinitely (the foreground service holds the process alive). LaunchedEffect keyed on
88 // permission.id so a fresh prompt resets the timer. Disabled when the timeout is 0.
88 // permission.id so a fresh prompt resets the timer. Disabled when the timeout is 0.
89 val autoRejectMs = NetworkConfig.permissionAutoRejectMs
89 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)
93 LaunchedEffect(permission.id, autoRejectMs) {
90 LaunchedEffect(permission.id, autoRejectMs) {
94 if (autoRejectMs <= 0L) return@LaunchedEffect
91 if (autoRejectMs <= 0L) return@LaunchedEffect
95 val tickMs = 1000L
92 val tickMs = 1000L
@@ -102,14 +99,22 @@ fun PermissionDialog(
102 respond(PermissionResponse.REJECT)
99 respond(PermissionResponse.REJECT)
103 }
100 }
104 val showReminder = elapsedMs >= NetworkConfig.permissionReminderThresholdMs
101 val showReminder = elapsedMs >= NetworkConfig.permissionReminderThresholdMs
105− // In the final 10 seconds, show a per-second countdown instead of the minute reminder.
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).
106− val showSecondsWarning = autoRejectMs > 0L && elapsedMs >= warningThresholdMs
105+ val showSecondsWarning = autoRejectMs > 0L && elapsedMs >= autoRejectMs - 60_000L
107 AlertDialog(
106 AlertDialog(
108 // Back press routes here (dismissOnBackPress defaults true) and is treated as an
107 // Back press routes here (dismissOnBackPress defaults true) and is treated as an
109 // explicit reject — the safe default. Tap-outside is disabled below so it can't
108 // explicit reject — the safe default. Tap-outside is disabled below so it can't
110 // dismiss silently. A host-composition BackHandler doesn't work: AlertDialog
109 // dismiss silently. A host-composition BackHandler doesn't work: AlertDialog
111 // renders in its own window whose back dispatcher never reaches the host.
110 // renders in its own window whose back dispatcher never reaches the host.
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)
112− onDismissRequest = { respond(PermissionResponse.REJECT) },
116+ respond(PermissionResponse.REJECT)
117+ },
113 properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
118 properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
114 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
119 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
115 title = {
120 title = {
@@ -212,6 +217,16 @@ fun PermissionDialog(
212 },
217 },
213 modifier = Modifier.fillMaxWidth(),
218 modifier = Modifier.fillMaxWidth(),
214 ) { Text(stringResource(R.string.allow_for_session)) }
219 ) { 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+ )
215 OutlinedButton(
230 OutlinedButton(
216 onClick = {
231 onClick = {
217 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
232 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
@@ -219,14 +234,6 @@ fun PermissionDialog(
219 },
234 },
220 modifier = Modifier.fillMaxWidth(),
235 modifier = Modifier.fillMaxWidth(),
221 ) { Text(stringResource(R.string.always_allow)) }
236 ) { 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− )
230 // Reject is an OutlinedButton (not a low-prominence TextButton) in the
237 // Reject is an OutlinedButton (not a low-prominence TextButton) in the
231 // same column so the actions are visually balanced — the prior layout buried
238 // same column so the actions are visually balanced — the prior layout buried
232 // Reject as a small dismiss-button, nudging users toward granting. Reject
239 // 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
11import androidx.compose.runtime.mutableStateOf
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
12import androidx.compose.runtime.remember
13import androidx.compose.ui.platform.LocalContext
13import androidx.compose.ui.platform.LocalContext
14+import soy.iko.opencode.data.network.NetworkConfig
14
15
15/**
16/**
16 * Remember a [TtsController] scoped to the current composition; it shuts the engine down
17 * Remember a [TtsController] scoped to the current composition; it shuts the engine down
@@ -185,11 +186,15 @@ class TtsController(context: Context) : RememberObserver {
185 /**
186 /**
186 * Split [text] into segments no longer than [TextToSpeech.getMaxSpeechInputLength] so
187 * Split [text] into segments no longer than [TextToSpeech.getMaxSpeechInputLength] so
187 * every part can actually be enqueued. Prefers sentence/paragraph then whitespace
188 * every part can actually be enqueued. Prefers sentence/paragraph then whitespace
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.
188− * boundaries, packing greedily, and hard-slices any single piece that still overflows.
192+ * Hard-slices any single piece that still overflows the absolute max.
189 */
193 */
190 private fun chunkForTts(text: String): List<String> {
194 private fun chunkForTts(text: String): List<String> {
191− val max = TextToSpeech.getMaxSpeechInputLength().coerceAtLeast(1)
195+ val hardMax = TextToSpeech.getMaxSpeechInputLength().coerceAtLeast(1)
196+ val packTarget = NetworkConfig.ttsChunkTargetMaxChars.coerceAtMost(hardMax)
192− if (text.length <= max) return listOf(text)
197+ if (text.length <= packTarget) return listOf(text)
193 val chunks = ArrayList<String>()
198 val chunks = ArrayList<String>()
194 val current = StringBuilder()
199 val current = StringBuilder()
195 fun flush() {
200 fun flush() {
@@ -197,16 +202,16 @@ class TtsController(context: Context) : RememberObserver {
197 }
202 }
198 for (piece in text.split(Regex("(?<=[.!?\\n])\\s+"))) {
203 for (piece in text.split(Regex("(?<=[.!?\\n])\\s+"))) {
199 var p = piece
204 var p = piece
200− // A single piece with no usable boundary can still exceed the limit: hard-slice it.
205+ // A single piece with no usable boundary can still exceed the hard limit: hard-slice it.
201− while (p.length > max) {
206+ while (p.length > hardMax) {
202 flush()
207 flush()
203− chunks.add(p.substring(0, max))
208+ chunks.add(p.substring(0, hardMax))
204− p = p.substring(max)
209+ p = p.substring(hardMax)
205 }
210 }
206 when {
211 when {
207 p.isEmpty() -> Unit
212 p.isEmpty() -> Unit
208 current.isEmpty() -> current.append(p)
213 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)
210 else -> { flush(); current.append(p) }
215 else -> { flush(); current.append(p) }
211 }
216 }
212 }
217 }
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+78−19
@@ -42,14 +42,21 @@ import soy.iko.opencode.data.network.EventStreamClient
42import soy.iko.opencode.data.network.NetworkConfig
42import soy.iko.opencode.data.network.NetworkConfig
43import soy.iko.opencode.di.AppContainer
43import soy.iko.opencode.di.AppContainer
44
44
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+
45/**
49/**
46 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
50 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
47 * session list so a dropped stream is visible everywhere, not just mid-conversation.
51 * session list so a dropped stream is visible everywhere, not just mid-conversation.
48 *
52 *
49 * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
53 * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
50 * when [onRetry] is supplied, so the user can re-connect without hunting for the
54 * 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
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
52− * connecting/reconnecting states remain non-interactive (the system is already trying).
59+ * remain non-interactive (the system is already trying).
53 */
60 */
54@Composable
61@Composable
55fun ConnectionBanner(
62fun ConnectionBanner(
@@ -57,6 +64,8 @@ fun ConnectionBanner(
57 modifier: Modifier = Modifier,
64 modifier: Modifier = Modifier,
58 isOnline: Boolean = true,
65 isOnline: Boolean = true,
59 onRetry: (() -> Unit)? = null,
66 onRetry: (() -> Unit)? = null,
67+ onEditCredentials: (() -> Unit)? = null,
68+ reconnectAttempts: Int = 0,
60) {
69) {
61 // When the device itself has no connectivity, surface that distinctly (instead of
70 // When the device itself has no connectivity, surface that distinctly (instead of
62 // the SSE state) — a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
71 // the SSE state) — a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
@@ -65,8 +74,18 @@ fun ConnectionBanner(
65 stringResource(R.string.offline)
74 stringResource(R.string.offline)
66 } else {
75 } else {
67 when (state) {
76 when (state) {
68− EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
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+ }
69− EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
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+ }
70 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
89 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
71 EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
90 EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
72 EventStreamClient.ConnectionState.Connected -> null
91 EventStreamClient.ConnectionState.Connected -> null
@@ -157,26 +176,56 @@ fun ConnectionBanner(
157 .padding(start = 6.dp)
176 .padding(start = 6.dp)
158 .weight(1f, fill = false),
177 .weight(1f, fill = false),
159 )
178 )
160− // Inline retry on the Failed banner so the user can recover without
179+ // Inline recovery action on a hard failure so the user can recover without
161− // leaving the screen. Hidden during transient states (the system is
180+ // leaving the screen. Hidden during transient states (the system is already
162− // already reconnecting), when offline (retry can't help), and when no
181+ // reconnecting), when offline (retry can't help), and when no callback is wired.
163− // callback is wired (callers that don't have a reconnect path).
164− if (isServerFailure && !isOffline && onRetry != null) {
182+ if (isServerFailure && !isOffline) {
165− TextButton(
183+ ConnectionRecoveryButton(
166− onClick = onRetry,
184+ state = state,
167− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
185+ onRetry = onRetry,
168− modifier = Modifier
186+ onEditCredentials = onEditCredentials,
169− .defaultMinSize(minHeight = 48.dp)
187+ onContainer = onContainer,
170− .testTag("connection_retry"),
188+ )
171− ) {
172− Text(stringResource(R.string.retry_now), color = onContainer)
173− }
174 }
189 }
175 }
190 }
176 }
191 }
177 }
192 }
178}
193}
179
194
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+
180/**
229/**
181 * Convenience wrapper that collects the SSE connection state and device connectivity from
230 * Convenience wrapper that collects the SSE connection state and device connectivity from
182 * [container] and renders a [ConnectionBanner] aligned to the top center of the calling
231 * [container] and renders a [ConnectionBanner] aligned to the top center of the calling
@@ -186,7 +235,10 @@ fun ConnectionBanner(
186 */
235 */
187@OptIn(ExperimentalCoroutinesApi::class)
236@OptIn(ExperimentalCoroutinesApi::class)
188@Composable
237@Composable
189−fun BoxScope.ConnectionBannerFor(container: AppContainer) {
238+fun BoxScope.ConnectionBannerFor(
239+ container: AppContainer,
240+ onEditCredentials: (() -> Unit)? = null,
241+) {
190 // flatMapLatest without stateIn: collectAsStateWithLifecycle handles the lifecycle and
242 // flatMapLatest without stateIn: collectAsStateWithLifecycle handles the lifecycle and
191 // gives an initial value. No ViewModel scope needed, so this works on any screen.
243 // gives an initial value. No ViewModel scope needed, so this works on any screen.
192 val connectionState by produceState(
244 val connectionState by produceState(
@@ -197,11 +249,18 @@ fun BoxScope.ConnectionBannerFor(container: AppContainer) {
197 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
249 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
198 .collect { value = it }
250 .collect { value = it }
199 }
251 }
252+ val reconnectAttempts by produceState(initialValue = 0, container) {
253+ container.activeConnection
254+ .flatMapLatest { it?.events?.reconnectAttempts ?: flowOf(0) }
255+ .collect { value = it }
256+ }
200 val isOnline by container.isOnline.collectAsStateWithLifecycle()
257 val isOnline by container.isOnline.collectAsStateWithLifecycle()
201 ConnectionBanner(
258 ConnectionBanner(
202 state = connectionState,
259 state = connectionState,
203 modifier = Modifier.align(Alignment.TopCenter),
260 modifier = Modifier.align(Alignment.TopCenter),
204 isOnline = isOnline,
261 isOnline = isOnline,
205 onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
262 onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
263+ onEditCredentials = onEditCredentials,
264+ reconnectAttempts = reconnectAttempts,
206 )
265 )
207}
266}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+7−8
@@ -68,8 +68,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
68import androidx.compose.runtime.setValue
68import androidx.compose.runtime.setValue
69import androidx.compose.ui.Alignment
69import androidx.compose.ui.Alignment
70import androidx.compose.ui.Modifier
70import androidx.compose.ui.Modifier
71−import androidx.compose.ui.focus.FocusRequester
72−import androidx.compose.ui.focus.focusRequester
73import androidx.compose.ui.graphics.Color
71import androidx.compose.ui.graphics.Color
74import androidx.compose.ui.graphics.graphicsLayer
72import androidx.compose.ui.graphics.graphicsLayer
75import androidx.compose.ui.platform.LocalContext
73import androidx.compose.ui.platform.LocalContext
@@ -171,7 +169,8 @@ fun FileBrowserScreen(
171 .graphicsLayer { scaleX = scale; scaleY = scale },
169 .graphicsLayer { scaleX = scale; scaleY = scale },
172 ) {
170 ) {
173 // Surface SSE connection state so a dropped stream is visible while browsing
171 // 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.
175 Box(modifier = Modifier.fillMaxWidth()) {
174 Box(modifier = Modifier.fillMaxWidth()) {
176 ConnectionBannerFor(container)
175 ConnectionBannerFor(container)
177 }
176 }
@@ -182,17 +181,17 @@ fun FileBrowserScreen(
182 modifier = Modifier.fillMaxWidth(),
181 modifier = Modifier.fillMaxWidth(),
183 )
182 )
184 val keyboardController = LocalSoftwareKeyboardController.current
183 val keyboardController = LocalSoftwareKeyboardController.current
185− // Search is the primary reason to open this screen, so focus the field on first
184+ // Don't auto-focus the search field on entry: doing so raises the IME keyboard
186− // composition. Focus only (no forced keyboard) so it doesn't fight the breadcrumb/listing.
185+ // over the directory listing (the other primary function of this screen) and
187− val searchFocus = remember { FocusRequester() }
186+ // forces a browse-first user to dismiss it. The session/server lists don't
188− LaunchedEffect(Unit) { runCatchingCancellable { searchFocus.requestFocus() } }
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.
189 OutlinedTextField(
189 OutlinedTextField(
190 value = state.query,
190 value = state.query,
191 onValueChange = vm::setQuery,
191 onValueChange = vm::setQuery,
192 modifier = Modifier
192 modifier = Modifier
193 .fillMaxWidth()
193 .fillMaxWidth()
194 .padding(start = 12.dp, end = 12.dp, top = 12.dp)
194 .padding(start = 12.dp, end = 12.dp, top = 12.dp)
195− .focusRequester(searchFocus)
196 .testTag("file_search"),
195 .testTag("file_search"),
197 label = {
196 label = {
198 Text(
197 Text(
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+16−1
@@ -38,6 +38,7 @@ import androidx.compose.foundation.text.KeyboardOptions
38import androidx.compose.foundation.text.selection.SelectionContainer
38import androidx.compose.foundation.text.selection.SelectionContainer
39import androidx.compose.material.icons.Icons
39import androidx.compose.material.icons.Icons
40import androidx.compose.material.icons.automirrored.filled.ArrowBack
40import androidx.compose.material.icons.automirrored.filled.ArrowBack
41+import androidx.compose.material.icons.automirrored.filled.Chat
41import androidx.compose.material.icons.automirrored.filled.WrapText
42import androidx.compose.material.icons.automirrored.filled.WrapText
42import androidx.compose.material.icons.filled.Close
43import androidx.compose.material.icons.filled.Close
43import androidx.compose.material.icons.filled.ContentCopy
44import androidx.compose.material.icons.filled.ContentCopy
@@ -295,6 +296,14 @@ fun FileViewScreen(
295 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
296 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
296 }
297 }
297 },
298 },
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+ },
298 )
307 )
299 },
308 },
300 snackbarHost = { SnackbarHost(snackbar) },
309 snackbarHost = { SnackbarHost(snackbar) },
@@ -505,6 +514,7 @@ private fun FileViewTopBar(
505 onCopyPath: () -> Unit,
514 onCopyPath: () -> Unit,
506 onShare: () -> Unit,
515 onShare: () -> Unit,
507 onOpenExternally: () -> Unit,
516 onOpenExternally: () -> Unit,
517+ onOpenInChat: () -> Unit,
508) {
518) {
509 TopAppBar(
519 TopAppBar(
510 title = {
520 title = {
@@ -567,7 +577,7 @@ private fun FileViewTopBar(
567 // title isn't crowded off screen by five inline icons on a phone.
577 // title isn't crowded off screen by five inline icons on a phone.
568 var menuExpanded by remember { mutableStateOf(false) }
578 var menuExpanded by remember { mutableStateOf(false) }
569 IconButton(onClick = { menuExpanded = true }) {
579 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))
571 }
581 }
572 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
582 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
573 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
583 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
@@ -600,6 +610,11 @@ private fun FileViewTopBar(
600 onClick = { onCopyPath(); menuExpanded = false },
610 onClick = { onCopyPath(); menuExpanded = false },
601 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
611 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
602 )
612 )
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+ )
603 DropdownMenuItem(
618 DropdownMenuItem(
604 text = { Text(stringResource(R.string.share)) },
619 text = { Text(stringResource(R.string.share)) },
605 onClick = { onShare(); menuExpanded = false },
620 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
34import androidx.compose.material3.DropdownMenuItem
34import androidx.compose.material3.DropdownMenuItem
35import androidx.compose.material3.ExperimentalMaterial3Api
35import androidx.compose.material3.ExperimentalMaterial3Api
36import androidx.compose.material3.FilterChip
36import androidx.compose.material3.FilterChip
37−import androidx.compose.material3.FloatingActionButton
37+import androidx.compose.material3.ExtendedFloatingActionButton
38import androidx.compose.material3.Icon
38import androidx.compose.material3.Icon
39import androidx.compose.material3.IconButton
39import androidx.compose.material3.IconButton
40import androidx.compose.material3.MaterialTheme
40import androidx.compose.material3.MaterialTheme
@@ -112,11 +112,15 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
112 snackbarHost = { SnackbarHost(snackbar) },
112 snackbarHost = { SnackbarHost(snackbar) },
113 floatingActionButton = {
113 floatingActionButton = {
114 // Register a new MCP server dynamically (POST /mcp). Hidden until a connection is
114 // 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.
116 if (state !is McpViewModel.State.Disconnected) {
118 if (state !is McpViewModel.State.Disconnected) {
119+ ExtendedFloatingActionButton(
117− FloatingActionButton(onClick = { showAddDialog = true }) {
120+ onClick = { showAddDialog = true },
118− Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.mcp_add))
121+ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
119− }
122+ text = { Text(stringResource(R.string.mcp_add_server)) },
123+ )
120 }
124 }
121 },
125 },
122 ) { padding ->
126 ) { padding ->
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+4−4
@@ -23,7 +23,6 @@ import kotlinx.serialization.json.putJsonArray
23import kotlinx.serialization.json.putJsonObject
23import kotlinx.serialization.json.putJsonObject
24import soy.iko.opencode.di.AppContainer
24import soy.iko.opencode.di.AppContainer
25import soy.iko.opencode.util.runCatchingCancellable
25import soy.iko.opencode.util.runCatchingCancellable
26−import soy.iko.opencode.util.safeExceptionSummary
27
26
28/** The shape of an MCP server the user is registering from the Add dialog. */
27/** The shape of an MCP server the user is registering from the Add dialog. */
29enum class McpKind { LOCAL, REMOTE }
28enum class McpKind { LOCAL, REMOTE }
@@ -39,8 +38,9 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
39 sealed interface State {
38 sealed interface State {
40 data object Loading : State
39 data object Loading : State
41 data object Disconnected : State
40 data object Disconnected : State
42− /** MCP servers failed to load. [message] carries a short, safe summary so the user
41+ /** MCP servers failed to load. [message] carries a localized, human-friendly summary
43− * can tell an auth failure from a transient network drop or an old server without /mcp. */
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. */
44 data class Error(val message: String? = null) : State
44 data class Error(val message: String? = null) : State
45 data class Ready(val servers: List<McpServerInfo>) : State
45 data class Ready(val servers: List<McpServerInfo>) : State
46 }
46 }
@@ -100,7 +100,7 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
100 }
100 }
101 }
101 }
102 .onSuccess { _state.value = State.Ready(it) }
102 .onSuccess { _state.value = State.Ready(it) }
103− .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
103+ .onFailure { _state.value = State.Error(container.friendlyError(it)) }
104 _refreshing.value = false
104 _refreshing.value = false
105 }
105 }
106
106
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+25−4
@@ -35,6 +35,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
35import androidx.compose.material3.FilterChip
35import androidx.compose.material3.FilterChip
36import androidx.compose.material3.Icon
36import androidx.compose.material3.Icon
37import androidx.compose.material3.IconButton
37import androidx.compose.material3.IconButton
38+import androidx.compose.material3.LinearProgressIndicator
38import androidx.compose.material3.MaterialTheme
39import androidx.compose.material3.MaterialTheme
39import androidx.compose.material3.OutlinedTextField
40import androidx.compose.material3.OutlinedTextField
40import androidx.compose.material3.Scaffold
41import androidx.compose.material3.Scaffold
@@ -154,18 +155,24 @@ fun GlobalSearchScreen(
154 Box(modifier = Modifier.fillMaxSize()) {
155 Box(modifier = Modifier.fillMaxSize()) {
155 ConnectionBannerFor(container)
156 ConnectionBannerFor(container)
156 val searchingLabel = stringResource(R.string.searching)
157 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()
157 // Crossfade between content states so transitions read as a smooth fade instead
164 // Crossfade between content states so transitions read as a smooth fade instead
158 // of an instant snap. Matches the session list's Crossfade pattern; reduced
165 // of an instant snap. Matches the session list's Crossfade pattern; reduced
159 // motion is honored by Crossfade's default spec.
166 // motion is honored by Crossfade's default spec.
160 val stateKey = globalSearchStateKey(state)
167 val stateKey = globalSearchStateKey(state)
161− @Suppress("UnusedCrossfadeTargetStateParameter")
168+ @Suppress("UnusedCrossfadeTargetStateParameter")
162− Crossfade(
169+ Crossfade(
163 targetState = stateKey,
170 targetState = stateKey,
164 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
171 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
165 label = "global_search_state",
172 label = "global_search_state",
166 ) {
173 ) {
167 when {
174 when {
168− state.searching -> Column(
175+ state.searching && !showResultsWhileSearching -> Column(
169 modifier = Modifier.align(Alignment.Center),
176 modifier = Modifier.align(Alignment.Center),
170 horizontalAlignment = Alignment.CenterHorizontally,
177 horizontalAlignment = Alignment.CenterHorizontally,
171 ) {
178 ) {
@@ -267,6 +274,18 @@ fun GlobalSearchScreen(
267 }
274 }
268 }
275 }
269 }
276 }
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+ }
270 }
289 }
271 }
290 }
272 }
291 }
@@ -274,8 +293,10 @@ fun GlobalSearchScreen(
274
293
275/** Maps the global-search view state to a stable string key for the Crossfade, so the screen's
294/** Maps the global-search view state to a stable string key for the Crossfade, so the screen's
276 * content-state transitions fade smoothly. Extracted from [GlobalSearchScreen] to keep its
295 * 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. */
278private fun globalSearchStateKey(state: GlobalSearchState): String = when {
298private fun globalSearchStateKey(state: GlobalSearchState): String = when {
299+ state.searching && state.hasSearched && state.results.isNotEmpty() -> "results"
279 state.searching -> "searching"
300 state.searching -> "searching"
280 state.error != null -> "error"
301 state.error != null -> "error"
281 state.hasSearched && state.results.isEmpty() -> "no_matches"
302 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
41import androidx.compose.material3.Text
41import androidx.compose.material3.Text
42import androidx.compose.material3.TextButton
42import androidx.compose.material3.TextButton
43import androidx.compose.material3.TopAppBar
43import androidx.compose.material3.TopAppBar
44+import androidx.compose.material3.TopAppBarDefaults
44import androidx.compose.runtime.Composable
45import androidx.compose.runtime.Composable
45import androidx.compose.runtime.LaunchedEffect
46import androidx.compose.runtime.LaunchedEffect
46import androidx.compose.runtime.getValue
47import androidx.compose.runtime.getValue
@@ -52,6 +53,7 @@ import androidx.compose.ui.Alignment
52import androidx.compose.ui.Modifier
53import androidx.compose.ui.Modifier
53import androidx.compose.ui.focus.FocusRequester
54import androidx.compose.ui.focus.FocusRequester
54import androidx.compose.ui.focus.focusRequester
55import androidx.compose.ui.focus.focusRequester
56+import androidx.compose.ui.input.nestedscroll.nestedScroll
55import androidx.compose.ui.platform.LocalContext
57import androidx.compose.ui.platform.LocalContext
56import androidx.compose.ui.platform.LocalSoftwareKeyboardController
58import androidx.compose.ui.platform.LocalSoftwareKeyboardController
57import androidx.compose.ui.platform.testTag
59import androidx.compose.ui.platform.testTag
@@ -109,6 +111,10 @@ fun ServerEditScreen(
109 if (!state.saving) showDiscardConfirm = true
111 if (!state.saving) showDiscardConfirm = true
110 }
112 }
111
113
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()
112 Scaffold(
118 Scaffold(
113 topBar = {
119 topBar = {
114 TopAppBar(
120 TopAppBar(
@@ -128,10 +134,15 @@ fun ServerEditScreen(
128 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
134 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
129 }
135 }
130 if (showDeleteConfirm) {
136 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
131 AlertDialog(
142 AlertDialog(
132 onDismissRequest = { showDeleteConfirm = false },
143 onDismissRequest = { showDeleteConfirm = false },
133 title = { Text(stringResource(R.string.delete_server)) },
144 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)) },
135 confirmButton = {
146 confirmButton = {
136 TextButton(onClick = {
147 TextButton(onClick = {
137 showDeleteConfirm = false
148 showDeleteConfirm = false
@@ -145,6 +156,7 @@ fun ServerEditScreen(
145 }
156 }
146 }
157 }
147 },
158 },
159+ scrollBehavior = scrollBehavior,
148 )
160 )
149 },
161 },
150 ) { padding ->
162 ) { padding ->
@@ -159,6 +171,7 @@ fun ServerEditScreen(
159 passwordVisible = passwordVisible,
171 passwordVisible = passwordVisible,
160 onTogglePassword = { passwordVisible = !passwordVisible },
172 onTogglePassword = { passwordVisible = !passwordVisible },
161 padding = padding,
173 padding = padding,
174+ scrollBehavior = scrollBehavior,
162 vm = vm,
175 vm = vm,
163 onDone = onDone,
176 onDone = onDone,
164 )
177 )
@@ -183,12 +196,14 @@ fun ServerEditScreen(
183 }
196 }
184}
197}
185
198
199+@OptIn(ExperimentalMaterial3Api::class)
186@Composable
200@Composable
187private fun ServerEditForm(
201private fun ServerEditForm(
188 state: ServerEditState,
202 state: ServerEditState,
189 passwordVisible: Boolean,
203 passwordVisible: Boolean,
190 onTogglePassword: () -> Unit,
204 onTogglePassword: () -> Unit,
191 padding: androidx.compose.foundation.layout.PaddingValues,
205 padding: androidx.compose.foundation.layout.PaddingValues,
206+ scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
192 vm: ServerEditViewModel,
207 vm: ServerEditViewModel,
193 onDone: () -> Unit,
208 onDone: () -> Unit,
194) {
209) {
@@ -218,6 +233,7 @@ private fun ServerEditForm(
218 .wrapContentWidth(Alignment.CenterHorizontally)
233 .wrapContentWidth(Alignment.CenterHorizontally)
219 .widthIn(max = 600.dp)
234 .widthIn(max = 600.dp)
220 .padding(16.dp)
235 .padding(16.dp)
236+ .nestedScroll(scrollBehavior.nestedScrollConnection)
221 .verticalScroll(rememberScrollState()),
237 .verticalScroll(rememberScrollState()),
222 verticalArrangement = Arrangement.spacedBy(12.dp),
238 verticalArrangement = Arrangement.spacedBy(12.dp),
223 ) {
239 ) {
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+27−5
@@ -292,11 +292,33 @@ fun ServerListScreen(
292 Column(modifier = Modifier.fillMaxSize().padding(top = padding.calculateTopPadding())) {
292 Column(modifier = Modifier.fillMaxSize().padding(top = padding.calculateTopPadding())) {
293 if (reconnecting && profiles.isNotEmpty()) {
293 if (reconnecting && profiles.isNotEmpty()) {
294 val reconnectingLabel = stringResource(R.string.reconnecting)
294 val reconnectingLabel = stringResource(R.string.reconnecting)
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+ ) {
295− LinearProgressIndicator(
308+ LinearProgressIndicator(
296− modifier = Modifier
309+ modifier = Modifier
297− .fillMaxWidth()
310+ .weight(1f, fill = false)
311+ .padding(end = 8.dp)
298− .semantics { contentDescription = reconnectingLabel },
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,
299− )
320+ )
321+ }
300 }
322 }
301 // Crossfade between content states (loading/empty/list) so the transition reads as a
323 // Crossfade between content states (loading/empty/list) so the transition reads as a
302 // smooth fade instead of an instant snap. Matches the session list's Crossfade
324 // 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() {
141 result.fold(
141 result.fold(
142 onSuccess = { ok ->
142 onSuccess = { ok ->
143 // probeWithCredentials returns false on 401/403: the server was
143 // probeWithCredentials returns false on 401/403: the server was
144− // reached but the credentials were rejected. Surface that as a
144+ // reached but auth failed. Distinguish "server requires auth but
145− // failure rather than a false "Connected", so the user isn't
145+ // no credentials were sent" from "credentials were sent and
146− // misled into thinking auth is fine.
146+ // rejected" so the user gets actionable feedback (enter creds vs
147+ // fix creds) instead of a single ambiguous "check credentials".
148+ if (ok) {
147− if (ok) ProbeTestResult.Success(latency)
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+ }
148− else ProbeTestResult.Failed(container.string(R.string.connection_failed))
157+ ProbeTestResult.Failed(msg)
158+ }
149 },
159 },
150 onFailure = { ProbeTestResult.Failed(container.friendlyError(it)) },
160 onFailure = { ProbeTestResult.Failed(container.friendlyError(it)) },
151 ),
161 ),
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+54−30
@@ -154,6 +154,7 @@ fun SessionListScreen(
154 onOpenSettings: () -> Unit,
154 onOpenSettings: () -> Unit,
155 onAddServer: () -> Unit,
155 onAddServer: () -> Unit,
156 onOpenSearch: () -> Unit = {},
156 onOpenSearch: () -> Unit = {},
157+ onEditProfile: ((String) -> Unit)? = null,
157 // Incremented by a host (the two-pane empty-detail pane) to open the new-session
158 // Incremented by a host (the two-pane empty-detail pane) to open the new-session
158 // directory picker, so that pane shares this screen's dialog instead of bypassing it.
159 // directory picker, so that pane shares this screen's dialog instead of bypassing it.
159 externalNewSessionTrigger: Int = 0,
160 externalNewSessionTrigger: Int = 0,
@@ -166,6 +167,7 @@ fun SessionListScreen(
166 val profiles by vm.profiles.collectAsStateWithLifecycle()
167 val profiles by vm.profiles.collectAsStateWithLifecycle()
167 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
168 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
168 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
169 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
170+ val reconnectAttempts by vm.reconnectAttempts.collectAsStateWithLifecycle()
169 val unread by vm.unread.collectAsStateWithLifecycle()
171 val unread by vm.unread.collectAsStateWithLifecycle()
170 val mutedSessions by container.mutedSessions.collectAsStateWithLifecycle()
172 val mutedSessions by container.mutedSessions.collectAsStateWithLifecycle()
171 val creating by vm.creating.collectAsStateWithLifecycle()
173 val creating by vm.creating.collectAsStateWithLifecycle()
@@ -435,7 +437,13 @@ fun SessionListScreen(
435 DropdownMenuItem(
437 DropdownMenuItem(
436 text = { Text(stringResource(R.string.mark_all_read)) },
438 text = { Text(stringResource(R.string.mark_all_read)) },
437 leadingIcon = { Icon(Icons.Filled.DoneAll, contentDescription = null) },
439 leadingIcon = { Icon(Icons.Filled.DoneAll, contentDescription = null) },
440+ onClick = {
438− onClick = { showMainMenu = false; container.clearAllUnread() },
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+ },
439 )
447 )
440 }
448 }
441 DropdownMenuItem(
449 DropdownMenuItem(
@@ -509,6 +517,11 @@ fun SessionListScreen(
509 state = connectionState,
517 state = connectionState,
510 isOnline = isOnline,
518 isOnline = isOnline,
511 onRetry = { vm.retryConnection() },
519 onRetry = { vm.retryConnection() },
520+ onEditCredentials = {
521+ val profileId = activeConnection?.profile?.id
522+ if (profileId != null) onEditProfile?.invoke(profileId)
523+ },
524+ reconnectAttempts = reconnectAttempts,
512 )
525 )
513 Box(modifier = Modifier.fillMaxSize()) {
526 Box(modifier = Modifier.fillMaxSize()) {
514 SessionListBody(
527 SessionListBody(
@@ -899,16 +912,20 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
899 // list away from recency, so date headers would mislead).
912 // list away from recency, so date headers would mislead).
900 val showGroups = state.sortMode == SessionSortMode.RECENT &&
913 val showGroups = state.sortMode == SessionSortMode.RECENT &&
901 !inSelection && state.query.isEmpty() && state.directoryFilter == null
914 !inSelection && state.query.isEmpty() && state.directoryFilter == null
902− // Resolve section labels once (in the @Composable scope) so buildGroupedEntries
915+ // Resolve section labels once (in the @Composable scope) so buildGroupedSegments
903− // can stay a plain function. Captured into the file-level lateinit fields.
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(
904− pinnedLabel = stringResource(R.string.session_pinned)
920+ pinned = stringResource(R.string.session_pinned),
905− todayLabel = stringResource(R.string.today)
921+ today = stringResource(R.string.today),
906− yesterdayLabel = stringResource(R.string.yesterday)
922+ yesterday = stringResource(R.string.yesterday),
907− lastWeekLabel = stringResource(R.string.last_week)
923+ lastWeek = stringResource(R.string.last_week),
908− olderLabel = stringResource(R.string.older)
924+ older = stringResource(R.string.older),
925+ )
909 val groupedSegments = remember(nodes, renderCap, state.pinnedIds, showGroups) {
926 val groupedSegments = remember(nodes, renderCap, state.pinnedIds, showGroups) {
910 val capped = if (nodes.size <= renderCap) nodes else nodes.take(renderCap)
927 val capped = if (nodes.size <= renderCap) nodes else nodes.take(renderCap)
911− buildGroupedSegments(capped, state.pinnedIds, showGroups)
928+ buildGroupedSegments(capped, state.pinnedIds, showGroups, dateLabels)
912 }
929 }
913 PullToRefreshBox(
930 PullToRefreshBox(
914 isRefreshing = refreshing,
931 isRefreshing = refreshing,
@@ -1122,6 +1139,24 @@ private fun sessionDateGroup(session: soy.iko.opencode.data.model.Session, now:
1122 }
1139 }
1123}
1140}
1124
1141
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+
1125/** Build the grouped entry list for the LazyColumn. Inserts a "Pinned" header before pinned
1160/** Build the grouped entry list for the LazyColumn. Inserts a "Pinned" header before pinned
1126 * sessions (when [showGroups] is true and pinned sessions exist), and date-group headers
1161 * sessions (when [showGroups] is true and pinned sessions exist), and date-group headers
1127 * (Today / Yesterday / Last week / Older) between unpinned sessions when [showGroups] is
1162 * (Today / Yesterday / Last week / Older) between unpinned sessions when [showGroups] is
@@ -1132,6 +1167,7 @@ private fun buildGroupedSegments(
1132 nodes: List<SessionNode>,
1167 nodes: List<SessionNode>,
1133 pinnedIds: Set<String>,
1168 pinnedIds: Set<String>,
1134 showGroups: Boolean,
1169 showGroups: Boolean,
1170+ labels: DateGroupLabels,
1135): List<Pair<SessionListEntry.Header?, List<SessionListEntry.Node>>> {
1171): List<Pair<SessionListEntry.Header?, List<SessionListEntry.Node>>> {
1136 if (nodes.isEmpty()) return emptyList()
1172 if (nodes.isEmpty()) return emptyList()
1137 val now = System.currentTimeMillis()
1173 val now = System.currentTimeMillis()
@@ -1146,7 +1182,7 @@ private fun buildGroupedSegments(
1146 fun maybeAddPinnedSegment(): MutableList<SessionListEntry.Node>? {
1182 fun maybeAddPinnedSegment(): MutableList<SessionListEntry.Node>? {
1147 if (pinnedIds.isEmpty()) return null
1183 if (pinnedIds.isEmpty()) return null
1148 if (!pinnedHeaderAdded[0]) {
1184 if (!pinnedHeaderAdded[0]) {
1149− ensureSegment(SessionListEntry.Header("__pinned", pinnedLabel))
1185+ ensureSegment(SessionListEntry.Header("__pinned", labels.pinned))
1150 pinnedHeaderAdded[0] = true
1186 pinnedHeaderAdded[0] = true
1151 }
1187 }
1152 return segments.last().second
1188 return segments.last().second
@@ -1162,7 +1198,7 @@ private fun buildGroupedSegments(
1162 val group = sessionDateGroup(node.session, now)
1198 val group = sessionDateGroup(node.session, now)
1163 if (dateHeaderAdded[group] != true) {
1199 if (dateHeaderAdded[group] != true) {
1164 dateHeaderAdded[group] = true
1200 dateHeaderAdded[group] = true
1165− ensureSegment(SessionListEntry.Header("__date_$group", dateGroupLabel(group)))
1201+ ensureSegment(SessionListEntry.Header("__date_$group", dateGroupLabel(group, labels)))
1166 }
1202 }
1167 segments.last().second.add(SessionListEntry.Node(node))
1203 segments.last().second.add(SessionListEntry.Node(node))
1168 } else {
1204 } else {
@@ -1177,23 +1213,6 @@ private fun buildGroupedSegments(
1177 return segments
1213 return segments
1178}
1214}
1179
1215
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−
1197/** A date-group section header in the session list. Rendered as a muted titleSmall label
1216/** A date-group section header in the session list. Rendered as a muted titleSmall label
1198 * with heading semantics so TalkBack users can skip between groups. */
1217 * with heading semantics so TalkBack users can skip between groups. */
1199@Composable
1218@Composable
@@ -1714,7 +1733,12 @@ private fun SessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snackbar
1714 val result = snackbar.showSnackbar(
1733 val result = snackbar.showSnackbar(
1715 message = message,
1734 message = message,
1716 actionLabel = undoLabel,
1735 actionLabel = undoLabel,
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).
1717− duration = androidx.compose.material3.SnackbarDuration.Short,
1741+ duration = androidx.compose.material3.SnackbarDuration.Long,
1718 )
1742 )
1719 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1743 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1720 vm.undoSessionAction(event)
1744 vm.undoSessionAction(event)
@@ -1740,7 +1764,7 @@ private fun BulkSessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snac
1740 val result = snackbar.showSnackbar(
1764 val result = snackbar.showSnackbar(
1741 message = message,
1765 message = message,
1742 actionLabel = undoLabel,
1766 actionLabel = undoLabel,
1743− duration = androidx.compose.material3.SnackbarDuration.Short,
1767+ duration = androidx.compose.material3.SnackbarDuration.Long,
1744 )
1768 )
1745 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1769 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1746 vm.undoBulkAction(event)
1770 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() {
154 initialValue = EventStreamClient.ConnectionState.Disconnected,
154 initialValue = EventStreamClient.ConnectionState.Disconnected,
155 )
155 )
156
156
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+
157 /** Sessions with activity that arrived while not being viewed, mapped to their
168 /** Sessions with activity that arrived while not being viewed, mapped to their
158 * unread event count so the list can show "N unread" instead of a bare dot. */
169 * unread event count so the list can show "N unread" instead of a bare dot. */
159 val unread: StateFlow<Map<String, Int>> = container.unread
170 val unread: StateFlow<Map<String, Int>> = container.unread
@@ -875,6 +886,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
875 _transientInfo.tryEmit(container.string(R.string.bulk_marked_read, ids.size))
886 _transientInfo.tryEmit(container.string(R.string.bulk_marked_read, ids.size))
876 }
887 }
877
888
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+
878 /** Mark a single session as unread (re-badges it in the list). */
898 /** Mark a single session as unread (re-badges it in the list). */
879 fun markUnread(id: String) {
899 fun markUnread(id: String) {
880 container.markSessionUnread(id)
900 container.markSessionUnread(id)
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+25−11
@@ -50,6 +50,7 @@ fun TwoPaneSessionChat(
50 onDisconnect: () -> Unit,
50 onDisconnect: () -> Unit,
51 onAddServer: () -> Unit,
51 onAddServer: () -> Unit,
52 onOpenSearch: () -> Unit = {},
52 onOpenSearch: () -> Unit = {},
53+ onEditProfile: ((String) -> Unit)? = null,
53 modifier: Modifier = Modifier,
54 modifier: Modifier = Modifier,
54) {
55) {
55 var selected by rememberSaveable { mutableStateOf<String?>(null) }
56 var selected by rememberSaveable { mutableStateOf<String?>(null) }
@@ -77,16 +78,30 @@ fun TwoPaneSessionChat(
77 }
78 }
78 }
79 }
79
80
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+
80 // Clear the selection when the active server profile changes (a server switch via
88 // Clear the selection when the active server profile changes (a server switch via
81 // the ServerSwitcherMenu). Without this, `selected` still points at the old
89 // the ServerSwitcherMenu). Without this, `selected` still points at the old
82 // server's session id and the detail pane renders a ChatScreen for a session that
90 // server's session id and the detail pane renders a ChatScreen for a session that
83 // doesn't exist on the new server, showing a load error with no explanation.
91 // doesn't exist on the new server, showing a load error with no explanation.
84 // Skips the initial transition (null -> a profile id) so a deep link / pending
92 // Skips the initial transition (null -> a profile id) so a deep link / pending
85 // open session that sets `selected` on the same first composition isn't clobbered.
93 // 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.
86 var lastProfileId by remember { mutableStateOf<String?>(null) }
98 var lastProfileId by remember { mutableStateOf<String?>(null) }
87 LaunchedEffect(activeConnection?.profile?.id) {
99 LaunchedEffect(activeConnection?.profile?.id) {
88 val currentId = activeConnection?.profile?.id
100 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+ }
90 lastProfileId = currentId
105 lastProfileId = currentId
91 }
106 }
92
107
@@ -101,16 +116,13 @@ fun TwoPaneSessionChat(
101
116
102 // Auto-select the top of the sorted/filtered list into the detail pane so a wide screen
117 // Auto-select the top of the sorted/filtered list into the detail pane so a wide screen
103 // doesn't open on a blank right pane. Runs at most once per Activity instance (the
118 // 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
119+ // `autoSelected` latch above), and only once the first list load has completed. It
107− // completed. It defers to any explicit selection already present — a restored `selected`,
120+ // defers to any explicit selection already present — a restored `selected`, a
108− // a deep-link/notification (pendingOpenSession), or a user pick — and, since the latch
121+ // deep-link/notification (pendingOpenSession), or a user pick — and, since the latch
109 // is set the moment any of those wins, it never re-selects after a user clears the pane
122 // 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
123+ // (BackHandler) or a server switch nulls `selected` (and resets the latch). Using
111− // recency sort) means the auto-selected detail matches the top list row under the
124+ // state.filtered (not a raw recency sort) means the auto-selected detail matches the top
112− // active sort mode, pinned float, archived-hidden, and directory filter.
125+ // list row under the active sort mode, pinned float, archived-hidden, and directory filter.
113− var autoSelected by rememberSaveable { mutableStateOf(false) }
114 val autoSelectCandidate = sessionListState.filtered.firstOrNull()?.id
126 val autoSelectCandidate = sessionListState.filtered.firstOrNull()?.id
115 LaunchedEffect(
127 LaunchedEffect(
116 sessionListState.loading,
128 sessionListState.loading,
@@ -145,7 +157,7 @@ fun TwoPaneSessionChat(
145 Box(
157 Box(
146 modifier = Modifier
158 modifier = Modifier
147 .weight(NetworkConfig.twoPaneLeftWeight)
159 .weight(NetworkConfig.twoPaneLeftWeight)
148− .widthIn(max = 460.dp),
160+ .widthIn(max = NetworkConfig.twoPaneLeftMaxWidthDp.dp),
149 ) {
161 ) {
150 SessionListScreen(
162 SessionListScreen(
151 container = container,
163 container = container,
@@ -157,6 +169,7 @@ fun TwoPaneSessionChat(
157 onOpenSettings = onOpenSettings,
169 onOpenSettings = onOpenSettings,
158 onAddServer = onAddServer,
170 onAddServer = onAddServer,
159 onOpenSearch = onOpenSearch,
171 onOpenSearch = onOpenSearch,
172+ onEditProfile = onEditProfile,
160 externalNewSessionTrigger = newSessionTrigger,
173 externalNewSessionTrigger = newSessionTrigger,
161 selectedSessionId = selected,
174 selectedSessionId = selected,
162 )
175 )
@@ -189,6 +202,7 @@ fun TwoPaneSessionChat(
189 onBack = { selected = null },
202 onBack = { selected = null },
190 onOpenFile = onOpenFile,
203 onOpenFile = onOpenFile,
191 onOpenSession = { id -> selected = id },
204 onOpenSession = { id -> selected = id },
205+ onEditProfile = onEditProfile,
192 )
206 )
193 }
207 }
194 }
208 }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+27−4
@@ -292,11 +292,32 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
292 )
292 )
293 } else {
293 } else {
294 val filtered = remember(reports, crashQuery) { filterCrashReports(reports, crashQuery) }
294 val filtered = remember(reports, crashQuery) { filterCrashReports(reports, crashQuery) }
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+ }
295− LazyColumn(modifier = Modifier.fillMaxSize()) {
316+ LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
296 crashSearchHeader(reports = reports, filtered = filtered, crashQuery = crashQuery) {
317 crashSearchHeader(reports = reports, filtered = filtered, crashQuery = crashQuery) {
297 crashQuery = it
318 crashQuery = it
298 }
319 }
299− items(filtered, key = { it.fileName }) { report ->
320+ items(capped, key = { it.fileName }) { report ->
300 // rememberSaveable so an open row menu survives a rotation; BackHandler
321 // rememberSaveable so an open row menu survives a rotation; BackHandler
301 // so system back closes it instead of navigating away, matching the
322 // so system back closes it instead of navigating away, matching the
302 // session/server/file list dropdown back-handling.
323 // session/server/file list dropdown back-handling.
@@ -393,7 +414,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
393 IconButton(onClick = { menuExpanded = true }) {
414 IconButton(onClick = { menuExpanded = true }) {
394 Icon(
415 Icon(
395 Icons.Filled.MoreVert,
416 Icons.Filled.MoreVert,
396− contentDescription = stringResource(R.string.more_options),
417+ contentDescription = stringResource(R.string.more_options_for, report.fileName),
397 )
418 )
398 }
419 }
399 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
420 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
@@ -489,7 +510,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
489 IconButton(onClick = {
510 IconButton(onClick = {
490 // Confirm before deleting a single report, matching clear-all.
511 // Confirm before deleting a single report, matching clear-all.
491 pendingReportDelete = reportName
512 pendingReportDelete = reportName
513+ }, enabled = content != null) {
492− }) { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) }
514+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
515+ }
493 },
516 },
494 )
517 )
495 },
518 },
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+18
@@ -110,6 +110,7 @@ import soy.iko.opencode.di.AppContainer
110import soy.iko.opencode.R
110import soy.iko.opencode.R
111import soy.iko.opencode.data.network.NetworkConfig
111import soy.iko.opencode.data.network.NetworkConfig
112import soy.iko.opencode.ui.canAuthenticateForAppLock
112import soy.iko.opencode.ui.canAuthenticateForAppLock
113+import soy.iko.opencode.ui.components.ConnectionBanner
113import soy.iko.opencode.ui.theme.LightPaletteSwatches
114import soy.iko.opencode.ui.theme.LightPaletteSwatches
114import soy.iko.opencode.ui.theme.DarkPaletteSwatches
115import soy.iko.opencode.ui.theme.DarkPaletteSwatches
115import soy.iko.opencode.ui.theme.AmoledPaletteSwatches
116import soy.iko.opencode.ui.theme.AmoledPaletteSwatches
@@ -184,6 +185,13 @@ fun SettingsScreen(
184 }
185 }
185 val connectionState by connectionStateFlow
186 val connectionState by connectionStateFlow
186 .collectAsStateWithLifecycle(initialValue = EventStreamClient.ConnectionState.Disconnected)
187 .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)
187 val context = LocalContext.current
195 val context = LocalContext.current
188 // Crash count badge: surface that there are reports to look at without making the
196 // Crash count badge: surface that there are reports to look at without making the
189 // user open the screen to find out.
197 // user open the screen to find out.
@@ -292,6 +300,16 @@ fun SettingsScreen(
292 .nestedScroll(scrollBehavior.nestedScrollConnection)
300 .nestedScroll(scrollBehavior.nestedScrollConnection)
293 .padding(16.dp),
301 .padding(16.dp),
294 ) {
302 ) {
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+ )
295 val s = settings
313 val s = settings
296 // Resolve section-header strings once so the filter logic below can match
314 // Resolve section-header strings once so the filter logic below can match
297 // against them without re-resolving per recomposition. These mirror the
315 // against them without re-resolving per recomposition. These mirror the
app/src/main/res/values-es/strings.xml+18
@@ -46,6 +46,24 @@
46 <string name="not_connected">Sin conexión.</string>
46 <string name="not_connected">Sin conexión.</string>
47 <string name="no_servers_to_reconnect">No hay ningún servidor guardado al que reconectar.</string>
47 <string name="no_servers_to_reconnect">No hay ningún servidor guardado al que reconectar.</string>
48 <string name="retry_now">Reintentar ahora</string>
48 <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>
49
67
50 <!-- Server list -->
68 <!-- Server list -->
51 <string name="servers_title">Servidores de opencode</string>
69 <string name="servers_title">Servidores de opencode</string>
app/src/main/res/values/strings.xml+18
@@ -47,6 +47,24 @@
47 <string name="not_connected">Not connected.</string>
47 <string name="not_connected">Not connected.</string>
48 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
48 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
49 <string name="retry_now">Retry now</string>
49 <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>
50
68
51 <!-- Server list -->
69 <!-- Server list -->
52 <string name="servers_title">opencode servers</string>
70 <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
5import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.ExperimentalCoroutinesApi
6import kotlinx.coroutines.ExperimentalCoroutinesApi
7import kotlinx.coroutines.launch
7import kotlinx.coroutines.launch
8+import kotlinx.coroutines.flow.first
8import kotlinx.coroutines.flow.toList
9import kotlinx.coroutines.flow.toList
9import kotlinx.coroutines.test.StandardTestDispatcher
10import kotlinx.coroutines.test.StandardTestDispatcher
10import kotlinx.coroutines.test.TestScope
11import kotlinx.coroutines.test.TestScope
@@ -225,6 +226,34 @@ class ChatViewModelTest {
225 assertFalse(vm.running.value)
226 assertFalse(vm.running.value)
226 }
227 }
227
228
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+
228 // --- respondPermission() ---
257 // --- respondPermission() ---
229
258
230 @Test
259 @Test
@@ -334,7 +363,7 @@ class ChatViewModelTest {
334 // --- Connection state ---
363 // --- Connection state ---
335
364
336 @Test
365 @Test
337− fun sseDisconnected_resetsRunning() = testScope.runTest {
366+ fun sseDisconnected_keepsRunningAndFlagsInterrupted() = testScope.runTest {
338 val events = FakeEventStreamClient()
367 val events = FakeEventStreamClient()
339 val container = makeContainer(events = events)
368 val container = makeContainer(events = events)
340 val vm = makeVm(container)
369 val vm = makeVm(container)
@@ -342,7 +371,16 @@ class ChatViewModelTest {
342 testScope.testScheduler.advanceUntilIdle()
371 testScope.testScheduler.advanceUntilIdle()
343 events.fakeState.value = EventStreamClient.ConnectionState.Disconnected
372 events.fakeState.value = EventStreamClient.ConnectionState.Disconnected
344 testScope.testScheduler.advanceUntilIdle()
373 testScope.testScheduler.advanceUntilIdle()
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)
345− assertFalse(vm.running.value)
383+ assertFalse(vm.streamInterrupted.value)
346 }
384 }
347
385
348 @Test
386 @Test