git.iko.soy/opencode-android
on master help sign in
Implement UI/UX improvements: optimistic send with per-message status, in-chat delete undo, scroll-to-top, FGS task-plan progress, auth/endpoint failure split, unread persistence across reconnects, connect timeout, notification Stop/Mark-read/reply-sent actions, crash-relaunch prompt, notification rationale pre-prompt, splash icon, ServerCard dedupe
73868e9iko
app/detekt-baseline.xml+4
@@ -5,13 +5,17 @@
5 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
5 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
6 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
6 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
7 <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, onOpenSession: ((String) -&gt; Unit)? = null, )</ID>
7 <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, onOpenSession: ((String) -&gt; Unit)? = null, )</ID>
8+ <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
8 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
9 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
10+ <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
9 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
11 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
10 <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>
12 <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, )</ID>
13+ <ID>LargeClass:AppContainer.kt$AppContainer</ID>
11 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
14 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
12 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
15 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
13 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
16 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
14 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
17 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
18+ <ID>TooManyFunctions:CrashLogger.kt$CrashLogger</ID>
15 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
19 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
16 </CurrentIssues>
20 </CurrentIssues>
17</SmellBaseline>
21</SmellBaseline>
app/src/main/java/soy/iko/opencode/MainActivity.kt+36−2
@@ -104,6 +104,25 @@ class MainActivity : FragmentActivity() {
104 }
104 }
105 }
105 }
106 }
106 }
107+ // Surface a "crashed last time" prompt if a crash report from a previous run
108+ // hasn't been acknowledged. Acknowledges immediately so it doesn't re-fire on
109+ // every rotation/resume; the user can always find reports in Settings → Diagnostics.
110+ maybeShowCrashPrompt()
111+ }
112+
113+ private fun maybeShowCrashPrompt() {
114+ val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
115+ if (!crashLogger.hasUnacknowledgedCrash()) return
116+ crashLogger.acknowledgeCrashes()
117+ android.app.AlertDialog.Builder(this)
118+ .setTitle(R.string.crash_last_run_title)
119+ .setMessage(R.string.crash_last_run_text)
120+ .setPositiveButton(R.string.crash_last_run_view) { _, _ ->
121+ (application as OpencodeApp).container.requestDiagnostics()
122+ }
123+ .setNegativeButton(R.string.crash_last_run_dismiss, null)
124+ .setCancelable(true)
125+ .show()
107 }
126 }
108
127
109 override fun onNewIntent(intent: Intent) {
128 override fun onNewIntent(intent: Intent) {
@@ -235,7 +254,9 @@ class MainActivity : FragmentActivity() {
235 }
254 }
236
255
237 /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
256 /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
238− * Idempotent per Activity instance via [notificationPermissionRequested]. */
257+ * Idempotent per Activity instance via [notificationPermissionRequested].
258+ * Shows a rationale dialog first (explaining what the user gains) before the system
259+ * permission prompt, so a user who denies understands what they're missing. */
239 private fun maybeRequestNotificationPermission() {
260 private fun maybeRequestNotificationPermission() {
240 if (notificationPermissionRequested) return
261 if (notificationPermissionRequested) return
241 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
262 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
@@ -243,7 +264,20 @@ class MainActivity : FragmentActivity() {
243 PackageManager.PERMISSION_GRANTED
264 PackageManager.PERMISSION_GRANTED
244 if (!granted) {
265 if (!granted) {
245 notificationPermissionRequested = true
266 notificationPermissionRequested = true
246− requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
267+ showNotificationRationaleDialog()
247 }
268 }
248 }
269 }
270+
271+ private fun showNotificationRationaleDialog() {
272+ val ctx = this
273+ android.app.AlertDialog.Builder(ctx)
274+ .setTitle(R.string.notif_rationale_title)
275+ .setMessage(R.string.notif_rationale_text)
276+ .setPositiveButton(R.string.notif_rationale_allow) { _, _ ->
277+ runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
278+ }
279+ .setNegativeButton(R.string.notif_rationale_skip, null)
280+ .setCancelable(false)
281+ .show()
282+ }
249}
283}
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17−15
@@ -50,7 +50,7 @@ open class EventStreamClient(
50 HttpClient(io.ktor.client.engine.okhttp.OkHttp) {},
50 HttpClient(io.ktor.client.engine.okhttp.OkHttp) {},
51 CoroutineScope(EmptyCoroutineContext),
51 CoroutineScope(EmptyCoroutineContext),
52 )
52 )
53− enum class ConnectionState { Disconnected, Connecting, Connected, Failed }
53+ enum class ConnectionState { Disconnected, Connecting, Connected, Failed, AuthFailed }
54
54
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()
@@ -68,16 +68,12 @@ open class EventStreamClient(
68 /** Conflated channel used to cut the reconnect backoff short when network returns. */
68 /** Conflated channel used to cut the reconnect backoff short when network returns. */
69 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
69 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
70
70
71− /** Returns true if the exception is a non-retryable SSE failure that should stop the retry
71+ /** Classifies a non-retryable SSE failure so the UI can show the right recovery hint:
72− * loop and park until an explicit reconnect. This covers any 4xx except 408 (Request Timeout)
72+ * an auth failure (401/403) reads "check credentials", while other 4xx (400/404/405…)
73− * and 429 (Too Many Requests), which are transient — mirroring the REST layer's withRetry
73+ * reads "check the server URL and version". Returns null for transient/retryable errors. */
74− * classification (401/403 bad credentials, 404 renamed/removed endpoint, 400 bad request, ...
74+ private enum class SseFailureKind { Auth, Other }
75− * won't succeed on retry).
75+
76− * ktor's SSE builder wraps establishment failures in [io.ktor.client.plugins.sse.SSEClientException]
76+ private fun classifyNonRetryableSseFailure(e: Throwable): SseFailureKind? =
77− * (an IllegalStateException, NOT a ClientRequestException), attaching the response and keeping any
78− * underlying ClientRequestException only as the cause — so we inspect the wrapper's response status
79− * and walk the whole cause chain rather than matching a single exception type. */
80− private fun isNonRetryableSseFailure(e: Throwable): Boolean =
81 generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
77 generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
82 .take(16)
78 .take(16)
83 .mapNotNull { t ->
79 .mapNotNull { t ->
@@ -88,7 +84,12 @@ open class EventStreamClient(
88 }
84 }
89 }
85 }
90 // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
86 // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
87+ .map { code -> when {
91− .any { it in 400..499 && it != 408 && it != 429 }
88+ code in 400..499 && code != 408 && code != 429 ->
89+ if (code == 401 || code == 403) SseFailureKind.Auth else SseFailureKind.Other
90+ else -> null
91+ } }
92+ .firstOrNull { it != null }
92
93
93 /** Request an immediate reconnect, skipping any in-progress backoff. */
94 /** Request an immediate reconnect, skipping any in-progress backoff. */
94 open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
95 open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
@@ -254,13 +255,14 @@ open class EventStreamClient(
254 } catch (e: Exception) {
255 } catch (e: Exception) {
255 // If the scope was cancelled (e.g. connection close), the closed-client
256 // If the scope was cancelled (e.g. connection close), the closed-client
256 // exception is expected — don't log a spurious "stream error" warning.
257 // exception is expected — don't log a spurious "stream error" warning.
257− if (isNonRetryableSseFailure(e)) {
258+ val failureKind = classifyNonRetryableSseFailure(e)
259+ if (failureKind != null) {
258 // The server rejected the request with a non-retryable 4xx (bad
260 // The server rejected the request with a non-retryable 4xx (bad
259 // credentials, missing endpoint, ...) — retrying won't help. Log a
261 // credentials, missing endpoint, ...) — retrying won't help. Log a
260 // scrubbed summary (class + status) instead of the full exception, whose
262 // scrubbed summary (class + status) instead of the full exception, whose
261 // message carries the request URL and may include auth or paths.
263 // message carries the request URL and may include auth or paths.
262− Log.w("EventStream", "SSE non-retryable 4xx, awaiting explicit reconnect: ${safeExceptionSummary(e)}")
264+ Log.w("EventStream", "SSE non-retryable 4xx (${if (failureKind == SseFailureKind.Auth) "auth" else "endpoint"}), awaiting explicit reconnect: ${safeExceptionSummary(e)}")
263− _state.value = ConnectionState.Failed
265+ _state.value = if (failureKind == SseFailureKind.Auth) ConnectionState.AuthFailed else ConnectionState.Failed
264 if (!isActive) {
266 if (!isActive) {
265 // Final teardown (last subscriber left) while a 4xx surfaced: end in
267 // Final teardown (last subscriber left) while a 4xx surfaced: end in
266 // Disconnected like every other loop-exit path. Failed must stay
268 // Disconnected like every other loop-exit path. Failed must stay
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+6
@@ -233,6 +233,12 @@ object NetworkConfig {
233 * two-pane layout for top-level destination discoverability on large screens. */
233 * two-pane layout for top-level destination discoverability on large screens. */
234 const val navigationRailThresholdDp = 840
234 const val navigationRailThresholdDp = 840
235
235
236+ /** Maximum width (dp) for the chat message list on large screens. On a tablet or
237+ * unfolded foldable, full-width message bubbles stretch edge-to-edge, hurting
238+ * readability — capping and centering the list (like Gmail/Telegram on tablets)
239+ * keeps line lengths comfortable. Applied only above [twoPaneWidthThresholdDp]. */
240+ const val chatContentMaxWidthDp = 800
241+
236 // --- Motion tokens (shared across screens) ---
242 // --- Motion tokens (shared across screens) ---
237
243
238 /** Duration (ms) for horizontal slide push/pop nav transitions. */
244 /** Duration (ms) for horizontal slide push/pop nav transitions. */
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+18
@@ -43,6 +43,7 @@ class CrashLogger private constructor(private val appContext: Context) {
43
43
44 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
44 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
45 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
45 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
46+ private val crashPrefs = appContext.getSharedPreferences("crash_logger", android.content.Context.MODE_PRIVATE)
46
47
47 private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
48 private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
48 val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
49 val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
@@ -98,6 +99,21 @@ class CrashLogger private constructor(private val appContext: Context) {
98 }
99 }
99 }
100 }
100
101
102+ /** Whether any crash report is newer than the last-acknowledged timestamp — i.e. a
103+ * crash happened in a previous run that the user hasn't been prompted about yet.
104+ * Called on cold start to surface a "crashed last time" dialog. */
105+ fun hasUnacknowledgedCrash(): Boolean {
106+ val acknowledgedAt = crashPrefs.getLong(KEY_ACKNOWLEDGED_AT, 0L)
107+ return crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
108+ ?.any { it.lastModified() > acknowledgedAt } == true
109+ }
110+
111+ /** Mark all current crash reports as acknowledged so the cold-start prompt doesn't
112+ * re-fire on the next launch. */
113+ fun acknowledgeCrashes() {
114+ crashPrefs.edit().putLong(KEY_ACKNOWLEDGED_AT, System.currentTimeMillis()).apply()
115+ }
116+
101 /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
117 /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
102 private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
118 private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
103
119
@@ -235,6 +251,8 @@ class CrashLogger private constructor(private val appContext: Context) {
235 * crashes/ dir grows without limit until the user manually clears it. */
251 * crashes/ dir grows without limit until the user manually clears it. */
236 private const val MAX_REPORTS = 20
252 private const val MAX_REPORTS = 20
237
253
254+ private const val KEY_ACKNOWLEDGED_AT = "acknowledged_at"
255+
238 /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
256 /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
239 internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
257 internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
240
258
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+117−9
@@ -19,6 +19,12 @@ import soy.iko.opencode.data.model.StepFinishPart
19import soy.iko.opencode.data.model.ServerProfile
19import soy.iko.opencode.data.model.ServerProfile
20import soy.iko.opencode.data.model.SessionError
20import soy.iko.opencode.data.model.SessionError
21import soy.iko.opencode.data.model.SessionIdle
21import soy.iko.opencode.data.model.SessionIdle
22+import soy.iko.opencode.data.model.ToolPart
23+import soy.iko.opencode.data.model.TODO_WRITE_TOOL
24+import soy.iko.opencode.data.model.parseTodos
25+import soy.iko.opencode.data.model.statusEnum
26+import soy.iko.opencode.data.model.TodoStatus
27+import soy.iko.opencode.data.model.inputElement
22import soy.iko.opencode.data.repo.AttachmentDraftStore
28import soy.iko.opencode.data.repo.AttachmentDraftStore
23import soy.iko.opencode.data.repo.BackupManager
29import soy.iko.opencode.data.repo.BackupManager
24import soy.iko.opencode.data.repo.DraftStore
30import soy.iko.opencode.data.repo.DraftStore
@@ -48,13 +54,17 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
48import kotlinx.coroutines.SupervisorJob
54import kotlinx.coroutines.SupervisorJob
49import kotlinx.coroutines.cancel
55import kotlinx.coroutines.cancel
50import kotlinx.coroutines.delay
56import kotlinx.coroutines.delay
57+import kotlinx.coroutines.flow.MutableSharedFlow
51import kotlinx.coroutines.flow.MutableStateFlow
58import kotlinx.coroutines.flow.MutableStateFlow
59+import kotlinx.coroutines.flow.SharedFlow
52import kotlinx.coroutines.flow.StateFlow
60import kotlinx.coroutines.flow.StateFlow
61+import kotlinx.coroutines.flow.asSharedFlow
53import kotlinx.coroutines.flow.asStateFlow
62import kotlinx.coroutines.flow.asStateFlow
54import kotlinx.coroutines.flow.collectLatest
63import kotlinx.coroutines.flow.collectLatest
55import kotlinx.coroutines.flow.combine
64import kotlinx.coroutines.flow.combine
56import kotlinx.coroutines.flow.debounce
65import kotlinx.coroutines.flow.debounce
57import kotlinx.coroutines.flow.distinctUntilChanged
66import kotlinx.coroutines.flow.distinctUntilChanged
67+import kotlinx.coroutines.flow.drop
58import kotlinx.coroutines.flow.emptyFlow
68import kotlinx.coroutines.flow.emptyFlow
59import kotlinx.coroutines.flow.first
69import kotlinx.coroutines.flow.first
60import kotlinx.coroutines.Job
70import kotlinx.coroutines.Job
@@ -170,6 +180,13 @@ open class AppContainer private constructor(
170 }
180 }
171 }
181 }
172
182
183+ /** A one-shot signal to navigate to the Diagnostics screen (e.g. from the crash-relaunch
184+ * prompt). Consumed by OpencodeApp's NavHost. */
185+ private val _pendingDiagnostics = MutableStateFlow(false)
186+ open val pendingDiagnostics: StateFlow<Boolean> = _pendingDiagnostics.asStateFlow()
187+ open fun requestDiagnostics() { _pendingDiagnostics.value = true }
188+ open fun consumePendingDiagnostics(): Boolean = _pendingDiagnostics.compareAndSet(true, false)
189+
173 /**
190 /**
174 * A session id to open from an external trigger (a notification tap or a deep link),
191 * A session id to open from an external trigger (a notification tap or a deep link),
175 * paired with the originating server's profile id (if known) so a tap after the user
192 * paired with the originating server's profile id (if known) so a tap after the user
@@ -208,6 +225,12 @@ open class AppContainer private constructor(
208 private val _anyRunActive = MutableStateFlow(false)
225 private val _anyRunActive = MutableStateFlow(false)
209 open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
226 open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
210
227
228+ /** Latest task-plan progress text for the active run (e.g. "Step 2 of 5"), surfaced in
229+ * the foreground-service notification so the user can see what the agent is working on
230+ * without opening the app. Null when no plan is available or no run is active. */
231+ private val _runProgressText = MutableStateFlow<String?>(null)
232+ open val runProgressText: StateFlow<String?> = _runProgressText.asStateFlow()
233+
211 /** Whether the device currently has network connectivity. Distinct from the SSE
234 /** Whether the device currently has network connectivity. Distinct from the SSE
212 * connection state so the UI can tell "you're offline" (device) from "server
235 * connection state so the UI can tell "you're offline" (device) from "server
213 * unreachable" (credentials/host). Updated by the [networkCallback]. */
236 * unreachable" (credentials/host). Updated by the [networkCallback]. */
@@ -250,6 +273,9 @@ open class AppContainer private constructor(
250 unreadMessageIds.remove(id)
273 unreadMessageIds.remove(id)
251 }
274 }
252
275
276+ /** Mark a session as read (clear its unread badge) — the "Mark read" notification action. */
277+ open fun markSessionRead(id: String) = clearUnread(id)
278+
253 /** Restore a session's unread badge after a failed server switch reconnects.
279 /** Restore a session's unread badge after a failed server switch reconnects.
254 * Preserves the prior [count] so a session badged with "5 unread" before the
280 * Preserves the prior [count] so a session badged with "5 unread" before the
255 * switch attempt still shows 5 (not 1) after the restore. No-op for the session
281 * switch attempt still shows 5 (not 1) after the restore. No-op for the session
@@ -317,23 +343,45 @@ open class AppContainer private constructor(
317 .distinctUntilChanged()
343 .distinctUntilChanged()
318 .collect { active ->
344 .collect { active ->
319 if (active) {
345 if (active) {
320− // When exactly one run is active, resolve its title so the notification
346+ // When exactly one run is active, resolve its title (and id, for the
321− // identifies which session is running. Multiple concurrent runs fall back
347+ // Stop action) so the notification identifies which session is running
348+ // and can be aborted from the notification. Multiple concurrent runs
322− // to the generic title (naming one would be misleading).
349+ // fall back to the generic title (naming one would be misleading).
323− val title = synchronized(activeRuns) {
350+ val activeId = synchronized(activeRuns) {
324 activeRuns.toList().singleOrNull()
351 activeRuns.toList().singleOrNull()
352+ }
325− }?.let { sid ->
353+ val title = activeId?.let { sid ->
326 runCatchingCancellable {
354 runCatchingCancellable {
327 activeConnection.value?.repository?.listSessions()
355 activeConnection.value?.repository?.listSessions()
328 ?.firstOrNull { it.id == sid }?.displayTitle
356 ?.firstOrNull { it.id == sid }?.displayTitle
329 }.getOrNull()
357 }.getOrNull()
330 }
358 }
331− RunForegroundService.start(ctx, title)
359+ RunForegroundService.start(ctx, title, activeId, _runProgressText.value)
332 } else {
360 } else {
333 RunForegroundService.stop(ctx)
361 RunForegroundService.stop(ctx)
334 }
362 }
335 }
363 }
336 }
364 }
365+ // Separate collector: update the FGS notification's progress text ("Step 2 of 5")
366+ // as the agent works through its task plan. Re-sends the service intent with the
367+ // updated progress, which the service uses to rebuild the notification.
368+ appScope.launch {
369+ _runProgressText
370+ .drop(1) // skip initial value — the anyRunActive collector handles the first start
371+ .distinctUntilChanged()
372+ .collect { progress ->
373+ if (_anyRunActive.value) {
374+ val activeId = synchronized(activeRuns) { activeRuns.toList().singleOrNull() }
375+ val title = activeId?.let { sid ->
376+ runCatchingCancellable {
377+ activeConnection.value?.repository?.listSessions()
378+ ?.firstOrNull { it.id == sid }?.displayTitle
379+ }.getOrNull()
380+ }
381+ RunForegroundService.start(ctx, title, activeId, progress)
382+ }
383+ }
384+ }
337 }
385 }
338
386
339 /**
387 /**
@@ -544,6 +592,8 @@ open class AppContainer private constructor(
544 if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
592 if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
545 }
593 }
546 }
594 }
595+ // Clear the progress text when the run finishes.
596+ _runProgressText.value = null
547 if (wasRunning && !isActivelyViewing(idleSid)) {
597 if (wasRunning && !isActivelyViewing(idleSid)) {
548 // Fire this off the collector's coroutine: notifySessionCompleted
598 // Fire this off the collector's coroutine: notifySessionCompleted
549 // does a network listSessions() to resolve the title, and blocking
599 // does a network listSessions() to resolve the title, and blocking
@@ -554,6 +604,24 @@ open class AppContainer private constructor(
554 }
604 }
555 return@collect
605 return@collect
556 }
606 }
607+ // Track the agent's task-plan progress (from todowrite tool calls) so the
608+ // foreground-service notification can show "Step 2 of 5" instead of just a
609+ // chronometer. The todowrite tool sends the full plan on every call, so the
610+ // latest call's input is a complete snapshot.
611+ if (event is MessagePartUpdated) {
612+ val part = event.properties.part
613+ if (part is ToolPart && part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) {
614+ val todos = parseTodos(part.state.inputElement())
615+ if (todos.isNotEmpty()) {
616+ val completed = todos.count { it.statusEnum() == TodoStatus.COMPLETED }
617+ _runProgressText.value = appContext?.getString(
618+ soy.iko.opencode.R.string.notif_running_progress,
619+ completed + 1,
620+ todos.size,
621+ )
622+ }
623+ }
624+ }
557 val sid = sessionOf(event) ?: return@collect
625 val sid = sessionOf(event) ?: return@collect
558 // Re-read currentSession here, right before mutating the badge state:
626 // Re-read currentSession here, right before mutating the badge state:
559 // the user may have opened this session between when this event was
627 // the user may have opened this session between when this event was
@@ -782,12 +850,31 @@ open class AppContainer private constructor(
782 }.isSuccess
850 }.isSuccess
783 }
851 }
784 if (enqueued) flushOutbox()
852 if (enqueued) flushOutbox()
785− } finally {
853+ } finally {
786 onDone(enqueued)
854 onDone(enqueued)
787 }
855 }
788 }
856 }
789 }
857 }
790
858
859+ /** Abort the active run for [sessionId] from a notification Stop action. Routes to the
860+ * originating profile (or the active connection) so the abort reaches the server that
861+ * owns the session. Runs on the app scope so it survives the BroadcastReceiver's lifetime. */
862+ open fun abortRunFromNotification(sessionId: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
863+ appScope.launch {
864+ var success = false
865+ try {
866+ val conn = activeConnection.value?.takeIf {
867+ originProfileId == null || it.profile.id == originProfileId
868+ } ?: activeConnection.value
869+ if (conn != null) {
870+ success = runCatchingCancellable { conn.repository.abort(sessionId) }.isSuccess
871+ }
872+ } finally {
873+ onDone(success)
874+ }
875+ }
876+ }
877+
791 /** Extract the session id an event pertains to, for message-activity events. */
878 /** Extract the session id an event pertains to, for message-activity events. */
792 private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
879 private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
793
880
@@ -895,6 +982,10 @@ open class AppContainer private constructor(
895 /** Build and install a connection to [profile], replacing any current one. Caller MUST hold
982 /** Build and install a connection to [profile], replacing any current one. Caller MUST hold
896 * [connectionMutex]. */
983 * [connectionMutex]. */
897 private suspend fun connectLocked(profile: ServerProfile): OpencodeConnection {
984 private suspend fun connectLocked(profile: ServerProfile): OpencodeConnection {
985+ // Capture the previous profile id before closing, so we can preserve unread badges
986+ // when reconnecting to the same server — a brief network blip or manual reconnect
987+ // shouldn't erase "5 unread". Only a genuine server switch clears them.
988+ val previousProfileId = _activeConnection.value?.profile?.id
898 _activeConnection.value?.close()
989 _activeConnection.value?.close()
899 _activeConnection.value = null
990 _activeConnection.value = null
900 // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
991 // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
@@ -905,8 +996,10 @@ open class AppContainer private constructor(
905 activeRuns.clear()
996 activeRuns.clear()
906 _anyRunActive.value = false
997 _anyRunActive.value = false
907 }
998 }
999+ if (previousProfileId != profile.id) {
908− _unread.value = emptyMap()
1000+ _unread.value = emptyMap()
909− unreadMessageIds.clear()
1001+ unreadMessageIds.clear()
1002+ }
910 val now = System.currentTimeMillis()
1003 val now = System.currentTimeMillis()
911 val resolved = profileStore.resolve(profile)
1004 val resolved = profileStore.resolve(profile)
912 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
1005 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
@@ -999,6 +1092,21 @@ open class AppContainer private constructor(
999 * alive on the server, reappearing on the next refresh). */
1092 * alive on the server, reappearing on the next refresh). */
1000 private val pendingSessionDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
1093 private val pendingSessionDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
1001
1094
1095+ /** One-shot events carrying the id of a session scheduled for deferred deletion from
1096+ * outside the session list (e.g. from the chat screen's Delete). The SessionListScreen
1097+ * collects these to show an Undo snackbar, mirroring its own VM-level undo events. */
1098+ private val _externalSessionUndoEvents = MutableSharedFlow<String>(
1099+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
1100+ onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
1101+ )
1102+ val externalSessionUndoEvents: SharedFlow<String> = _externalSessionUndoEvents.asSharedFlow()
1103+
1104+ /** Emit an undo event for a session delete initiated outside the session list (e.g.
1105+ * from the chat screen). The SessionListScreen collects these to show an Undo snackbar. */
1106+ fun emitExternalSessionUndo(sessionId: String) {
1107+ _externalSessionUndoEvents.tryEmit(sessionId)
1108+ }
1109+
1002 /**
1110 /**
1003 * Schedule session [id] for deletion after [delayMs], cancellable via [cancelSessionDelete]
1111 * Schedule session [id] for deletion after [delayMs], cancellable via [cancelSessionDelete]
1004 * (the Undo action). Also clears the session's stored draft on a successful delete.
1112 * (the Undo action). Also clears the session's stored draft on a successful delete.
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+36−6
@@ -31,8 +31,34 @@ class NotificationActionReceiver : BroadcastReceiver() {
31
31
32 override fun onReceive(context: Context, intent: Intent) {
32 override fun onReceive(context: Context, intent: Intent) {
33 val container = (context.applicationContext as? OpencodeApp)?.container ?: return
33 val container = (context.applicationContext as? OpencodeApp)?.container ?: return
34+ if (intent.action == ACTION_STOP_RUN) {
35+ // Stop action from the foreground-service notification. The session id may be
36+ // absent if multiple runs were active (generic notification); in that case we
37+ // can't target a specific run — just let it ride.
38+ val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
39+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
40+ val pending = goAsync()
41+ val finished = AtomicBoolean(false)
42+ var watchdogThread: Thread? = null
43+ val finishOnce = {
44+ if (finished.compareAndSet(false, true)) {
45+ watchdogThread?.interrupt()
46+ pending.finish()
47+ }
48+ }
49+ watchdogThread = watchdog(finishOnce)
50+ container.abortRunFromNotification(sessionId, profileId) { _ -> finishOnce() }
51+ return
52+ }
34 val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
53 val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
35 when (intent.action) {
54 when (intent.action) {
55+ ACTION_MARK_READ -> {
56+ // "Mark read" from a completion notification — clear the unread badge and
57+ // dismiss the notification without opening the session.
58+ container.markSessionRead(sessionId)
59+ SessionNotifications.cancel(context, sessionId)
60+ return
61+ }
36 ACTION_PERMISSION -> {
62 ACTION_PERMISSION -> {
37 val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
63 val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
38 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
64 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
@@ -81,13 +107,15 @@ class NotificationActionReceiver : BroadcastReceiver() {
81 }
107 }
82 watchdogThread = watchdog(finishOnce)
108 watchdogThread = watchdog(finishOnce)
83 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
109 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
84− // The reply is durably queued (and flushed now if online); clear the
110+ // The reply is durably queued (and flushed now if online). Show a brief
85− // "session ready" notification. If the enqueue failed, cancel it anyway and
111+ // "Sent" confirmation by updating the completion notification's text, then
112+ // let it auto-cancel on tap — instead of vanishing silently. If the enqueue
86− // post a "reply not sent" error: leaving the completion notification up strands
113+ // failed, post a "reply not sent" error: leaving the completion notification
87− // SystemUI's RemoteInput field in a permanent "sending…" spinner that can't be
114+ // up strands SystemUI's RemoteInput field in a permanent "sending…" spinner
88− // resubmitted, so "leave it up to retry" doesn't actually let the user retry.
115+ // that can't be resubmitted, so "leave it up to retry" doesn't actually let
116+ // the user retry.
89 if (enqueued) {
117 if (enqueued) {
90− SessionNotifications.cancel(context, sessionId)
118+ SessionNotifications.postReplySent(context, sessionId)
91 } else {
119 } else {
92 SessionNotifications.postReplyFailed(context, sessionId, profileId)
120 SessionNotifications.postReplyFailed(context, sessionId, profileId)
93 }
121 }
@@ -115,6 +143,8 @@ class NotificationActionReceiver : BroadcastReceiver() {
115 companion object {
143 companion object {
116 const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
144 const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
117 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
145 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
146+ const val ACTION_STOP_RUN = "soy.iko.opencode.action.STOP_RUN"
147+ const val ACTION_MARK_READ = "soy.iko.opencode.action.MARK_READ"
118 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
148 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
119 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
149 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
120 const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
150 const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt+3−1
@@ -42,7 +42,9 @@ object NotificationChannels {
42 },
42 },
43 )
43 )
44 nm.createNotificationChannel(
44 nm.createNotificationChannel(
45+ // HIGH importance so a failed run stands out from a completed one by
46+ // sound/vibration — both are actionable, but a failure is time-sensitive.
45− NotificationChannel(ERROR, context.getString(R.string.notif_channel_error), NotificationManager.IMPORTANCE_DEFAULT).apply {
47+ NotificationChannel(ERROR, context.getString(R.string.notif_channel_error), NotificationManager.IMPORTANCE_HIGH).apply {
46 description = context.getString(R.string.notif_channel_error_desc)
48 description = context.getString(R.string.notif_channel_error_desc)
47 },
49 },
48 )
50 )
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+54−8
@@ -9,7 +9,9 @@ import android.content.pm.ServiceInfo
9import android.os.Build
9import android.os.Build
10import android.os.IBinder
10import android.os.IBinder
11import android.util.Log
11import android.util.Log
12+import android.annotation.SuppressLint
12import androidx.core.app.NotificationCompat
13import androidx.core.app.NotificationCompat
14+import androidx.core.app.NotificationManagerCompat
13import androidx.core.app.ServiceCompat
15import androidx.core.app.ServiceCompat
14import soy.iko.opencode.MainActivity
16import soy.iko.opencode.MainActivity
15import soy.iko.opencode.R
17import soy.iko.opencode.R
@@ -30,7 +32,9 @@ class RunForegroundService : Service() {
30 // the notification can identify which session is running, instead of a generic
32 // the notification can identify which session is running, instead of a generic
31 // "Agent is working…". Null/blank falls back to the generic title.
33 // "Agent is working…". Null/blank falls back to the generic title.
32 val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
34 val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
35+ val sessionId = intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
36+ val progress = intent?.getStringExtra(EXTRA_PROGRESS)?.takeIf { it.isNotBlank() }
33− val notification = buildNotification(sessionTitle)
37+ val notification = buildNotification(sessionTitle, sessionId, progress)
34 // startForeground can throw ForegroundServiceStartNotAllowedException on
38 // startForeground can throw ForegroundServiceStartNotAllowedException on
35 // Android 12+ if the app is in the background when the service starts. Wrap
39 // Android 12+ if the app is in the background when the service starts. Wrap
36 // it so a backgrounded start (e.g. the user navigates away at the wrong
40 // it so a backgrounded start (e.g. the user navigates away at the wrong
@@ -54,7 +58,7 @@ class RunForegroundService : Service() {
54 return START_NOT_STICKY
58 return START_NOT_STICKY
55 }
59 }
56
60
57− private fun buildNotification(sessionTitle: String?): Notification {
61+ private fun buildNotification(sessionTitle: String?, sessionId: String?, progress: String? = null): Notification {
58 // Tapping the notification opens the app so the user can see the running session.
62 // Tapping the notification opens the app so the user can see the running session.
59 val openIntent = Intent(this, MainActivity::class.java).apply {
63 val openIntent = Intent(this, MainActivity::class.java).apply {
60 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
64 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -63,8 +67,8 @@ class RunForegroundService : Service() {
63 this, 0, openIntent,
67 this, 0, openIntent,
64 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
68 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
65 )
69 )
66− val contentText = sessionTitle ?: getString(R.string.notif_running_text)
70+ val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
67− return NotificationCompat.Builder(this, NotificationChannels.STATUS)
71+ val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
68 .setSmallIcon(R.drawable.ic_launcher_foreground)
72 .setSmallIcon(R.drawable.ic_launcher_foreground)
69 .setContentTitle(getString(R.string.notif_running_title))
73 .setContentTitle(getString(R.string.notif_running_title))
70 .setContentText(contentText)
74 .setContentText(contentText)
@@ -77,24 +81,61 @@ class RunForegroundService : Service() {
77 .setShowWhen(true)
81 .setShowWhen(true)
78 .setPriority(NotificationCompat.PRIORITY_LOW)
82 .setPriority(NotificationCompat.PRIORITY_LOW)
79 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
83 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
84+ // Add a Stop action so the user can cancel the run without opening the app — the
85+ // core of the "kick it off and walk away" flow. Only when a specific session is
86+ // known (multiple concurrent runs can't be targeted individually).
87+ if (sessionId != null) {
88+ val stopIntent = Intent(this, NotificationActionReceiver::class.java).apply {
89+ action = NotificationActionReceiver.ACTION_STOP_RUN
90+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
91+ }
92+ val stopPending = PendingIntent.getBroadcast(
93+ this, sessionId.hashCode(), stopIntent,
94+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
95+ )
96+ builder.addAction(
97+ R.drawable.ic_launcher_foreground,
98+ getString(R.string.notif_action_stop),
99+ stopPending,
100+ )
101+ }
80− .build()
102+ return builder.build()
81 }
103 }
82
104
83 // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
105 // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
84 // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
106 // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
85 // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
107 // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
108+ // continues without foreground priority. Post a low-priority notification so the user
109+ // knows their long-running task may be paused in the background, rather than silently
86− // continues without foreground priority. Both overloads are covered (the 2-arg form is
110+ // losing foreground priority. Both overloads are covered (the 2-arg form is API 35+).
87− // API 35+).
88 override fun onTimeout(startId: Int) {
111 override fun onTimeout(startId: Int) {
89 Log.w(TAG, "Foreground service timed out; stopping")
112 Log.w(TAG, "Foreground service timed out; stopping")
113+ postTimeoutNotification()
90 stopSelf()
114 stopSelf()
91 }
115 }
92
116
93 override fun onTimeout(startId: Int, fgsType: Int) {
117 override fun onTimeout(startId: Int, fgsType: Int) {
94 Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
118 Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
119+ postTimeoutNotification()
95 stopSelf()
120 stopSelf()
96 }
121 }
97
122
123+ /** Post a low-priority notification informing the user that the foreground service was
124+ * timed out by the system — the run continues but may be killed by Doze without warning. */
125+ @SuppressLint("MissingPermission")
126+ private fun postTimeoutNotification() {
127+ runCatching {
128+ val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
129+ .setSmallIcon(R.drawable.ic_launcher_foreground)
130+ .setContentTitle(getString(R.string.notif_running_title))
131+ .setContentText(getString(R.string.notif_fg_timeout_text))
132+ .setAutoCancel(true)
133+ .setPriority(NotificationCompat.PRIORITY_LOW)
134+ .build()
135+ NotificationManagerCompat.from(this).notify(NOTIF_TIMEOUT_ID, notification)
136+ }
137+ }
138+
98 override fun onDestroy() {
139 override fun onDestroy() {
99 // Safety net: if the process is being killed while a run is in progress,
140 // Safety net: if the process is being killed while a run is in progress,
100 // ensure the notification is removed rather than lingering.
141 // ensure the notification is removed rather than lingering.
@@ -107,15 +148,20 @@ class RunForegroundService : Service() {
107 companion object {
148 companion object {
108 private const val TAG = "RunForegroundService"
149 private const val TAG = "RunForegroundService"
109 private const val NOTIF_ID = 1
150 private const val NOTIF_ID = 1
151+ private const val NOTIF_TIMEOUT_ID = 2
110 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
152 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
153+ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
154+ const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
111
155
112− fun start(context: Context, sessionTitle: String? = null) {
156+ fun start(context: Context, sessionTitle: String? = null, sessionId: String? = null, progress: String? = null) {
113 // startForegroundService can throw ForegroundServiceStartNotAllowedException
157 // startForegroundService can throw ForegroundServiceStartNotAllowedException
114 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
158 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
115 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
159 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
116 runCatching {
160 runCatching {
117 val intent = Intent(context, RunForegroundService::class.java)
161 val intent = Intent(context, RunForegroundService::class.java)
118 sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
162 sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
163+ sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
164+ progress?.let { intent.putExtra(EXTRA_PROGRESS, it) }
119 context.startForegroundService(intent)
165 context.startForegroundService(intent)
120 }.onFailure {
166 }.onFailure {
121 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
167 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+68
@@ -125,6 +125,22 @@ object SessionNotifications {
125 replyPending,
125 replyPending,
126 ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
126 ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
127
127
128+ // "Mark read" action so the user can triage a completion without opening the session.
129+ val markReadIntent = Intent(context, NotificationActionReceiver::class.java).apply {
130+ action = NotificationActionReceiver.ACTION_MARK_READ
131+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
132+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
133+ }
134+ val markReadPending = PendingIntent.getBroadcast(
135+ context, notifId + 100, markReadIntent,
136+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
137+ )
138+ val markReadAction = NotificationCompat.Action.Builder(
139+ R.drawable.ic_launcher_foreground,
140+ context.getString(R.string.notif_action_mark_read),
141+ markReadPending,
142+ ).build()
143+
128 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
144 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
129 .setSmallIcon(R.drawable.ic_launcher_foreground)
145 .setSmallIcon(R.drawable.ic_launcher_foreground)
130 .setContentTitle(context.getString(R.string.notif_completed_title))
146 .setContentTitle(context.getString(R.string.notif_completed_title))
@@ -135,6 +151,7 @@ object SessionNotifications {
135 .setAutoCancel(true)
151 .setAutoCancel(true)
136 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
152 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
137 .addAction(replyAction)
153 .addAction(replyAction)
154+ .addAction(markReadAction)
138 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
155 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
139 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
156 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
140 // Group notifications from the same server so multiple completions collapse into
157 // Group notifications from the same server so multiple completions collapse into
@@ -192,6 +209,7 @@ object SessionNotifications {
192 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
209 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
193 .setCategory(NotificationCompat.CATEGORY_CALL)
210 .setCategory(NotificationCompat.CATEGORY_CALL)
194 .setPriority(NotificationCompat.PRIORITY_HIGH)
211 .setPriority(NotificationCompat.PRIORITY_HIGH)
212+ .setGroup(GROUP_PERMISSION)
195
213
196 // One action per response. Each PendingIntent needs a distinct request code or
214 // One action per response. Each PendingIntent needs a distinct request code or
197 // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
215 // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
@@ -216,6 +234,10 @@ object SessionNotifications {
216
234
217 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
235 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
218 .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
236 .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
237+ // Group permission notifications so a multi-session burst collapses into a stackable
238+ // group instead of filling the shade — mirroring the completed-sessions grouping.
239+ runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
240+ .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
219 }
241 }
220
242
221 /** Post a notification when a background run fails. */
243 /** Post a notification when a background run fails. */
@@ -231,9 +253,34 @@ object SessionNotifications {
231 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
253 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
232 .setCategory(NotificationCompat.CATEGORY_ERROR)
254 .setCategory(NotificationCompat.CATEGORY_ERROR)
233 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
255 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
256+ // Group error notifications so a burst of failures collapses into a stackable group.
257+ .setGroup(GROUP_ERROR)
234 .build()
258 .build()
235 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
259 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
236 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
260 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
261+ runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
262+ .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
263+ }
264+
265+ /** Update the completion notification to show a brief "Sent" confirmation after a
266+ * successful inline reply, replacing the completion notification's content. Auto-cancels
267+ * on tap so it doesn't linger. */
268+ @SuppressLint("MissingPermission")
269+ fun postReplySent(context: Context, sessionId: String) {
270+ if (!canPost(context)) {
271+ cancel(context, sessionId)
272+ return
273+ }
274+ val notifId = notifId(NS_COMPLETED, sessionId)
275+ val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
276+ .setSmallIcon(R.drawable.ic_launcher_foreground)
277+ .setContentTitle(context.getString(R.string.notif_reply_sent))
278+ .setAutoCancel(true)
279+ .setPriority(NotificationCompat.PRIORITY_LOW)
280+ .setGroup(GROUP_COMPLETED)
281+ .build()
282+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
283+ .onFailure { Log.w(TAG, "Failed to post reply-sent notification", it) }
237 }
284 }
238
285
239 /** An inline reply couldn't be durably enqueued (no resolvable profile, or the outbox write
286 /** An inline reply couldn't be durably enqueued (no resolvable profile, or the outbox write
@@ -280,5 +327,26 @@ object SessionNotifications {
280 }
327 }
281
328
282 private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
329 private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
330+ private const val GROUP_PERMISSION = "soy.iko.opencode.PERMISSION"
331+ private const val GROUP_ERROR = "soy.iko.opencode.ERROR"
283 private const val SUMMARY_COMPLETED_ID = -1
332 private const val SUMMARY_COMPLETED_ID = -1
333+ private const val SUMMARY_PERMISSION_ID = -2
334+ private const val SUMMARY_ERROR_ID = -3
335+
336+ /** Post a summary notification for a group so multiple entries collapse into one stackable
337+ * entry in the shade instead of filling it. */
338+ @SuppressLint("MissingPermission")
339+ private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
340+ if (!canPost(context)) return
341+ val summary = NotificationCompat.Builder(context, channelId)
342+ .setSmallIcon(R.drawable.ic_launcher_foreground)
343+ .setContentTitle(context.getString(R.string.app_name))
344+ .setAutoCancel(true)
345+ .setGroup(group)
346+ .setGroupSummary(true)
347+ .build()
348+ runCatching {
349+ NotificationManagerCompat.from(context).notify(summaryId, summary)
350+ }.onFailure { Log.w(TAG, "Failed to post group summary notification", it) }
351+ }
284}
352}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+48−7
@@ -1,5 +1,7 @@
1package soy.iko.opencode.ui
1package soy.iko.opencode.ui
2
2
3+import androidx.compose.animation.EnterTransition
4+import androidx.compose.animation.ExitTransition
3import androidx.compose.animation.core.tween
5import androidx.compose.animation.core.tween
4import androidx.compose.animation.fadeIn
6import androidx.compose.animation.fadeIn
5import androidx.compose.animation.fadeOut
7import androidx.compose.animation.fadeOut
@@ -33,6 +35,7 @@ import soy.iko.opencode.ui.server.ServerListScreen
33import soy.iko.opencode.ui.search.GlobalSearchScreen
35import soy.iko.opencode.ui.search.GlobalSearchScreen
34import soy.iko.opencode.ui.components.LocalChatTextScale
36import soy.iko.opencode.ui.components.LocalChatTextScale
35import soy.iko.opencode.ui.components.LocalCodeWrap
37import soy.iko.opencode.ui.components.LocalCodeWrap
38+import soy.iko.opencode.ui.components.isReducedMotion
36import soy.iko.opencode.ui.session.SessionListScreen
39import soy.iko.opencode.ui.session.SessionListScreen
37import soy.iko.opencode.ui.session.TwoPaneSessionChat
40import soy.iko.opencode.ui.session.TwoPaneSessionChat
38import soy.iko.opencode.ui.settings.DiagnosticsScreen
41import soy.iko.opencode.ui.settings.DiagnosticsScreen
@@ -49,6 +52,7 @@ fun OpencodeApp(container: AppContainer) {
49 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
52 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
50 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
53 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
51 val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
54 val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
55+ val pendingDiagnostics by container.pendingDiagnostics.collectAsStateWithLifecycle()
52 val connection by container.activeConnection.collectAsStateWithLifecycle()
56 val connection by container.activeConnection.collectAsStateWithLifecycle()
53 // Chat presentation preferences, provided to the whole nav graph so the markdown/code
57 // Chat presentation preferences, provided to the whole nav graph so the markdown/code
54 // renderers honor the user's text-size and code-wrap choices everywhere.
58 // renderers honor the user's text-size and code-wrap choices everywhere.
@@ -100,6 +104,14 @@ fun OpencodeApp(container: AppContainer) {
100 }
104 }
101 }
105 }
102
106
107+ // Crash-relaunch prompt: navigate to the Diagnostics screen when the user taps
108+ // "View report" on the cold-start crash dialog. Doesn't require a connection.
109+ LaunchedEffect(pendingDiagnostics) {
110+ if (!pendingDiagnostics) return@LaunchedEffect
111+ container.consumePendingDiagnostics()
112+ navController.navigate(Routes.DIAGNOSTICS) { launchSingleTop = true }
113+ }
114+
103 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
115 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
104 // session and open it. The pending counter is consumed only *after* createSession
116 // session and open it. The pending counter is consumed only *after* createSession
105 // succeeds, so a transient failure leaves the request pending to retry (on the next
117 // succeeds, so a transient failure leaves the request pending to retry (on the next
@@ -136,6 +148,9 @@ fun OpencodeApp(container: AppContainer) {
136 LocalChatTextScale provides clampedChatTextScale,
148 LocalChatTextScale provides clampedChatTextScale,
137 LocalCodeWrap provides codeWrap,
149 LocalCodeWrap provides codeWrap,
138 ) {
150 ) {
151+ // Capture the reduced-motion preference once (in the composable scope) so the
152+ // non-composable NavHost transition lambdas can use it to skip animations.
153+ val reducedMotion = isReducedMotion()
139 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
154 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
140 // the open conversation side by side instead of a single-pane back stack.
155 // the open conversation side by side instead of a single-pane back stack.
141 BoxWithConstraints {
156 BoxWithConstraints {
@@ -179,17 +194,43 @@ fun OpencodeApp(container: AppContainer) {
179 NavHost(
194 NavHost(
180 navController = navController,
195 navController = navController,
181 startDestination = Routes.SERVERS,
196 startDestination = Routes.SERVERS,
182− // Slide horizontally for forward/back navigation to feel native; fade for
197+ // Motion-aware transitions: respect the user's reduced-motion setting
183− // the root so the first frame doesn't slide in from off-screen.
198+ // (Developer Options → animator scale 0) by skipping animations entirely.
199+ // Per-destination tuning: a chat→chat navigation (switching peer conversations
200+ // within the same surface) crossfades instead of sliding, so it reads as a peer
201+ // switch rather than drilling into a new section. Top-level destinations and
202+ // detail pushes keep the horizontal slide.
184 enterTransition = {
203 enterTransition = {
185− slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
204+ if (reducedMotion) {
205+ EnterTransition.None
206+ } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
186 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
207 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
208+ } else {
209+ slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
210+ fadeIn(tween(NetworkConfig.motionFadeDurationMs))
211+ }
187 },
212 },
188− exitTransition = { fadeOut(tween(NetworkConfig.motionFadeDurationMs)) },
213+ exitTransition = {
189− popEnterTransition = { fadeIn(tween(NetworkConfig.motionFadeDurationMs)) },
214+ if (reducedMotion) {
190− popExitTransition = {
215+ ExitTransition.None
191− slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
216+ } else {
192 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
217 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
218+ }
219+ },
220+ popEnterTransition = {
221+ if (reducedMotion) {
222+ EnterTransition.None
223+ } else {
224+ fadeIn(tween(NetworkConfig.motionFadeDurationMs))
225+ }
226+ },
227+ popExitTransition = {
228+ if (reducedMotion) {
229+ ExitTransition.None
230+ } else {
231+ slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
232+ fadeOut(tween(NetworkConfig.motionFadeDurationMs))
233+ }
193 },
234 },
194 ) {
235 ) {
195
236
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+67−25
@@ -27,7 +27,9 @@ import androidx.compose.foundation.layout.navigationBars
27import androidx.compose.foundation.layout.padding
27import androidx.compose.foundation.layout.padding
28import androidx.compose.foundation.layout.size
28import androidx.compose.foundation.layout.size
29import androidx.compose.foundation.layout.union
29import androidx.compose.foundation.layout.union
30+import androidx.compose.foundation.layout.widthIn
30import androidx.compose.foundation.layout.windowInsetsPadding
31import androidx.compose.foundation.layout.windowInsetsPadding
32+import androidx.compose.foundation.layout.wrapContentWidth
31import androidx.compose.foundation.lazy.LazyColumn
33import androidx.compose.foundation.lazy.LazyColumn
32import androidx.compose.foundation.lazy.items
34import androidx.compose.foundation.lazy.items
33import androidx.compose.foundation.lazy.rememberLazyListState
35import androidx.compose.foundation.lazy.rememberLazyListState
@@ -41,6 +43,7 @@ import androidx.compose.material.icons.filled.Description
41import androidx.compose.material.icons.filled.Edit
43import androidx.compose.material.icons.filled.Edit
42import androidx.compose.material.icons.filled.Expand
44import androidx.compose.material.icons.filled.Expand
43import androidx.compose.material.icons.filled.KeyboardArrowDown
45import androidx.compose.material.icons.filled.KeyboardArrowDown
46+import androidx.compose.material.icons.filled.KeyboardArrowUp
44import androidx.compose.material.icons.filled.Mic
47import androidx.compose.material.icons.filled.Mic
45import androidx.compose.material.icons.filled.MoreVert
48import androidx.compose.material.icons.filled.MoreVert
46import androidx.compose.material.icons.filled.PhotoCamera
49import androidx.compose.material.icons.filled.PhotoCamera
@@ -55,6 +58,7 @@ import androidx.compose.material3.Badge
55import androidx.compose.material3.BadgedBox
58import androidx.compose.material3.BadgedBox
56import androidx.compose.material3.Button
59import androidx.compose.material3.Button
57import androidx.compose.material3.CircularProgressIndicator
60import androidx.compose.material3.CircularProgressIndicator
61+import androidx.compose.material3.SmallFloatingActionButton
58import androidx.compose.material3.DropdownMenu
62import androidx.compose.material3.DropdownMenu
59import androidx.compose.material3.DropdownMenuItem
63import androidx.compose.material3.DropdownMenuItem
60import androidx.compose.material3.ExperimentalMaterial3Api
64import androidx.compose.material3.ExperimentalMaterial3Api
@@ -713,6 +717,7 @@ fun ChatScreen(
713 // recompose only this subtree, not the top bar / input bar / 20+ other
717 // recompose only this subtree, not the top bar / input bar / 20+ other
714 // state reads in the ChatScreen body.
718 // state reads in the ChatScreen body.
715 val messages by vm.messages.collectAsStateWithLifecycle()
719 val messages by vm.messages.collectAsStateWithLifecycle()
720+ val optimisticStatuses by vm.optimisticStatuses.collectAsStateWithLifecycle()
716 val listState = rememberLazyListState()
721 val listState = rememberLazyListState()
717 val contentScope = rememberCoroutineScope()
722 val contentScope = rememberCoroutineScope()
718 val timeTick = rememberRelativeTimeTick()
723 val timeTick = rememberRelativeTimeTick()
@@ -860,20 +865,36 @@ fun ChatScreen(
860 // separate "Not connected" state below).
865 // separate "Not connected" state below).
861 onRetry = { vm.refreshMessages() },
866 onRetry = { vm.refreshMessages() },
862 )
867 )
868+ // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
869+ // recovery is a swipe away everywhere — not just when the message list is
870+ // populated. Previously PTR was only around the populated list, leaving the
871+ // error/loading states reachable only via a button tap.
872+ PullToRefreshBox(
873+ isRefreshing = refreshing,
874+ onRefresh = { vm.refreshMessages() },
875+ // Cap width and center on large screens for readability — full-width
876+ // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
877+ modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
878+ .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
879+ ) {
880+ // Reserve top space when the banner is visible so it doesn't overlap the
881+ // first message / state content.
882+ val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
883+ val topPad = if (bannerVisible) 44.dp else 16.dp
863 if (loading && messages.isEmpty()) {
884 if (loading && messages.isEmpty()) {
864 val loadingLabel = stringResource(R.string.loading)
885 val loadingLabel = stringResource(R.string.loading)
865 CircularProgressIndicator(
886 CircularProgressIndicator(
866− Modifier
867− .align(Alignment.Center)
887+ Modifier.align(Alignment.Center).padding(top = topPad),
868− .semantics { contentDescription = loadingLabel },
888+ strokeWidth = 2.dp,
869 )
889 )
890+ Box(modifier = Modifier.semantics { contentDescription = loadingLabel })
870 } else if (loadError && messages.isEmpty()) {
891 } else if (loadError && messages.isEmpty()) {
871 // A failed load with nothing to show. Distinct from the empty
892 // A failed load with nothing to show. Distinct from the empty
872 // conversation state below — the conversation isn't empty, it
893 // conversation state below — the conversation isn't empty, it
873 // failed to load, and offering "Start a conversation" here is
894 // failed to load, and offering "Start a conversation" here is
874 // misleading. Offer a Retry instead, mirroring SessionListScreen.
895 // misleading. Offer a Retry instead, mirroring SessionListScreen.
875 Column(
896 Column(
876− modifier = Modifier.align(Alignment.Center).padding(24.dp),
897+ modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
877 horizontalAlignment = Alignment.CenterHorizontally,
898 horizontalAlignment = Alignment.CenterHorizontally,
878 ) {
899 ) {
879 Text(
900 Text(
@@ -891,7 +912,7 @@ fun ChatScreen(
891 // surface a clear starting state until the first part streams in.
912 // surface a clear starting state until the first part streams in.
892 val workingText = stringResource(R.string.working)
913 val workingText = stringResource(R.string.working)
893 Column(
914 Column(
894− modifier = Modifier.align(Alignment.Center).padding(24.dp),
915+ modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
895 horizontalAlignment = Alignment.CenterHorizontally,
916 horizontalAlignment = Alignment.CenterHorizontally,
896 ) {
917 ) {
897 CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
918 CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
@@ -906,19 +927,9 @@ fun ChatScreen(
906 // sending, instead of having to manually tap the field.
927 // sending, instead of having to manually tap the field.
907 runCatching { inputFocusRequester.requestFocus() }
928 runCatching { inputFocusRequester.requestFocus() }
908 },
929 },
909− modifier = Modifier.align(Alignment.Center),
930+ modifier = Modifier.align(Alignment.Center).padding(top = topPad),
910 )
931 )
911 } else {
932 } else {
912− // Pull-to-refresh wraps the message list so the user can force an SSE
913− // reconnect + re-seed with the same gesture they use on the session and
914− // file lists, instead of hunting for the top-bar refresh icon. The empty
915− // / loading / error states above stay outside the PTR box since there's
916− // no list content to pull against.
917− PullToRefreshBox(
918− isRefreshing = refreshing,
919− onRefresh = { vm.refreshMessages() },
920− modifier = Modifier.fillMaxSize(),
921− ) {
922 val lastMessageId = messages.lastOrNull()?.info?.id
933 val lastMessageId = messages.lastOrNull()?.info?.id
923 // Text-to-speech for reading assistant replies aloud; shuts down when the
934 // Text-to-speech for reading assistant replies aloud; shuts down when the
924 // message list leaves composition.
935 // message list leaves composition.
@@ -928,10 +939,11 @@ fun ChatScreen(
928 state = listState,
939 state = listState,
929 modifier = Modifier.fillMaxSize(),
940 modifier = Modifier.fillMaxSize(),
930 // Extra bottom padding reserves room for the jump-to-latest FAB so it
941 // Extra bottom padding reserves room for the jump-to-latest FAB so it
931− // never floats over the last message.
942+ // never floats over the last message. Top padding grows when the
943+ // connection banner is visible so it doesn't overlap the first message.
932 contentPadding = PaddingValues(
944 contentPadding = PaddingValues(
933 start = 16.dp,
945 start = 16.dp,
934− top = 16.dp,
946+ top = topPad,
935 end = 16.dp,
947 end = 16.dp,
936 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
948 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
937 ),
949 ),
@@ -985,6 +997,11 @@ fun ChatScreen(
985 runCatching { inputFocusRequester.requestFocus() }
997 runCatching { inputFocusRequester.requestFocus() }
986 },
998 },
987 onBranch = { text -> vm.branchFrom(text) },
999 onBranch = { text -> vm.branchFrom(text) },
1000+ sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1001+ if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1002+ else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1003+ },
1004+ isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
988 )
1005 )
989 }
1006 }
990 }
1007 }
@@ -1006,6 +1023,7 @@ fun ChatScreen(
1006 }
1023 }
1007 }
1024 }
1008 }
1025 }
1026+ }
1009 // Jump-to-latest affordance when the user has scrolled away during a stream.
1027 // Jump-to-latest affordance when the user has scrolled away during a stream.
1010 AnimatedVisibility(
1028 AnimatedVisibility(
1011 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1029 visible = !isPinnedToBottom && listItems.isNotEmpty(),
@@ -1039,6 +1057,27 @@ fun ChatScreen(
1039 )
1057 )
1040 }
1058 }
1041 }
1059 }
1060+ // Scroll-to-top affordance: a small FAB at the start edge, visible when the
1061+ // user has scrolled down past the first few messages. Symmetric to the
1062+ // jump-to-latest FAB, for long conversations where manual fling back is tedious.
1063+ val scrolledDown by remember {
1064+ derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 200 }
1065+ }
1066+ AnimatedVisibility(
1067+ visible = scrolledDown && listItems.size > 4,
1068+ modifier = Modifier.align(Alignment.BottomStart),
1069+ ) {
1070+ SmallFloatingActionButton(
1071+ onClick = {
1072+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } }
1073+ },
1074+ modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
1075+ ) {
1076+ Icon(
1077+ Icons.Filled.KeyboardArrowUp,
1078+ contentDescription = stringResource(R.string.scroll_to_top),
1079+ )
1080+ }
1042 }
1081 }
1043 }
1082 }
1044 }
1083 }
@@ -1354,15 +1393,18 @@ private fun ChatInputBar(
1354 }
1393 }
1355 },
1394 },
1356 keyboardOptions = KeyboardOptions(
1395 keyboardOptions = KeyboardOptions(
1357− // Always expose the platform default (newline) IME action so a soft-
1396+ // When "Send on Enter" is on, expose ImeAction.Send so the soft
1358− // keyboard user can insert a newline even when "Send on Enter" is on —
1397+ // keyboard shows a send key — matching the setting's intent and
1359− // soft keyboards have no Shift key, so the hardware Shift+Enter escape
1398+ // giving touch-only users a keyboard send affordance. When off,
1360− // hatch doesn't apply. The trailing Send button and the hardware Enter
1399+ // ImeAction.Default keeps the enter/newline key for multi-line input.
1361− // (onPreviewKeyEvent above) are the send affordances; the soft IME
1362− // action is the newline affordance.
1363− imeAction = ImeAction.Default,
1400+ imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
1364 capitalization = KeyboardCapitalization.Sentences,
1401 capitalization = KeyboardCapitalization.Sentences,
1365 ),
1402 ),
1403+ keyboardActions = KeyboardActions(
1404+ onSend = {
1405+ if (enabled && hasContent) onSend()
1406+ },
1407+ ),
1366 )
1408 )
1367 ComposerTrailingButton(
1409 ComposerTrailingButton(
1368 running = running,
1410 running = running,
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+119−20
@@ -7,6 +7,7 @@ import soy.iko.opencode.data.model.Command
7import soy.iko.opencode.data.model.FilePromptPart
7import soy.iko.opencode.data.model.FilePromptPart
8import soy.iko.opencode.data.model.MessageWithParts
8import soy.iko.opencode.data.model.MessageWithParts
9import soy.iko.opencode.data.model.ModelOption
9import soy.iko.opencode.data.model.ModelOption
10+import soy.iko.opencode.data.model.UserMessage
10import soy.iko.opencode.data.model.Permission
11import soy.iko.opencode.data.model.Permission
11import soy.iko.opencode.data.model.PermissionReplied
12import soy.iko.opencode.data.model.PermissionReplied
12import soy.iko.opencode.data.model.PermissionResponse
13import soy.iko.opencode.data.model.PermissionResponse
@@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.asStateFlow
38import kotlinx.coroutines.flow.receiveAsFlow
39import kotlinx.coroutines.flow.receiveAsFlow
39import kotlinx.coroutines.CancellationException
40import kotlinx.coroutines.CancellationException
40import kotlinx.coroutines.flow.collectLatest
41import kotlinx.coroutines.flow.collectLatest
42+import kotlinx.coroutines.flow.combine
41import kotlinx.coroutines.flow.debounce
43import kotlinx.coroutines.flow.debounce
42import kotlinx.coroutines.flow.distinctUntilChanged
44import kotlinx.coroutines.flow.distinctUntilChanged
43import kotlinx.coroutines.flow.drop
45import kotlinx.coroutines.flow.drop
@@ -166,7 +168,15 @@ class ChatViewModel(
166 private val _agentsReload = MutableStateFlow(0)
168 private val _agentsReload = MutableStateFlow(0)
167 private val _commandsReload = MutableStateFlow(0)
169 private val _commandsReload = MutableStateFlow(0)
168
170
171+ /** An optimistically-injected user message shown immediately on send, before the server
172+ * echoes the real message back via SSE. Removed once a matching UserMessage arrives. */
173+ data class OptimisticEntry(val tempId: String, val text: String, val timestamp: Long, val failed: Boolean)
174+
175+ private val _optimisticMessages = MutableStateFlow<List<OptimisticEntry>>(emptyList())
176+
177+ /** SSE-driven message stream with retry/backoff and loading/error side effects. Combined
178+ * with [_optimisticMessages] by [messages] so the user sees their outgoing prompt instantly. */
169− val messages: StateFlow<List<MessageWithParts>> =
179+ private val sseMessages: Flow<List<MessageWithParts>> =
170 container.activeConnection
180 container.activeConnection
171 .flatMapLatest { conn ->
181 .flatMapLatest { conn ->
172 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
182 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
@@ -185,6 +195,10 @@ class ChatViewModel(
185 if (it.isNotEmpty()) {
195 if (it.isNotEmpty()) {
186 hasShownMessages = true
196 hasShownMessages = true
187 }
197 }
198+ // Reconcile optimistic messages: drop any whose text now matches a real SSE
199+ // UserMessage (the server echoed the prompt back). Matching by trimmed text,
200+ // removing the oldest match first so duplicate texts are handled in order.
201+ reconcileOptimistic(it)
188 }
202 }
189 .retryWhen { cause, attempt ->
203 .retryWhen { cause, attempt ->
190 _loading.value = false
204 _loading.value = false
@@ -209,11 +223,15 @@ class ChatViewModel(
209 delay(backoffMs)
223 delay(backoffMs)
210 true
224 true
211 }
225 }
226+
227+ val messages: StateFlow<List<MessageWithParts>> =
228+ combine(sseMessages, _optimisticMessages) { sse, optimistic ->
229+ mergeOptimistic(sse, optimistic)
212− .stateIn(
230+ }.stateIn(
213− scope = viewModelScope,
231+ scope = viewModelScope,
214− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
232+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
215− initialValue = emptyList(),
233+ initialValue = emptyList(),
216− )
234+ )
217
235
218 /** Whether the conversation has any messages. Derived separately so the top bar
236 /** Whether the conversation has any messages. Derived separately so the top bar
219 * (share button enabled state) can observe this cheap boolean instead of the
237 * (share button enabled state) can observe this cheap boolean instead of the
@@ -227,6 +245,67 @@ class ChatViewModel(
227 initialValue = false,
245 initialValue = false,
228 )
246 )
229
247
248+ /** Map of optimistic message tempId → failed flag, so the UI can render a "Sending…"
249+ * or "Failed to send" indicator on the corresponding message bubble. */
250+ val optimisticStatuses: StateFlow<Map<String, Boolean>> = _optimisticMessages
251+ .map { entries -> entries.associate { it.tempId to it.failed } }
252+ .stateIn(
253+ scope = viewModelScope,
254+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
255+ initialValue = emptyMap(),
256+ )
257+
258+ /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
259+ * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
260+ * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
261+ * are kept so the user sees the failed indicator until they retry or dismiss. */
262+ private fun mergeOptimistic(
263+ sse: List<MessageWithParts>,
264+ optimistic: List<OptimisticEntry>,
265+ ): List<MessageWithParts> {
266+ if (optimistic.isEmpty()) return sse
267+ // Collect the text of real user messages so optimistic entries can be matched.
268+ val realUserTexts = mutableSetOf<String>()
269+ for (msg in sse) {
270+ if (msg.info !is UserMessage) continue
271+ val text = msg.parts.filterIsInstance<TextPart>()
272+ .joinToString("\n\n") { it.text }
273+ .trim()
274+ if (text.isNotEmpty()) realUserTexts.add(text)
275+ }
276+ val surviving = optimistic.filter { it.text.trim() !in realUserTexts }
277+ if (surviving.isEmpty()) return sse
278+ return sse + surviving.map { it.toMessageWithParts() }
279+ }
280+
281+ /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
282+ * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly. */
283+ private fun reconcileOptimistic(sse: List<MessageWithParts>) {
284+ if (_optimisticMessages.value.isEmpty()) return
285+ val realUserTexts = mutableSetOf<String>()
286+ for (msg in sse) {
287+ if (msg.info !is UserMessage) continue
288+ val text = msg.parts.filterIsInstance<TextPart>()
289+ .joinToString("\n\n") { it.text }
290+ .trim()
291+ if (text.isNotEmpty()) realUserTexts.add(text)
292+ }
293+ if (realUserTexts.isEmpty()) return
294+ _optimisticMessages.update { entries ->
295+ entries.filterNot { it.text.trim() in realUserTexts }
296+ }
297+ }
298+
299+ /** Convert an optimistic entry to a [MessageWithParts] for display. */
300+ private fun OptimisticEntry.toMessageWithParts(): MessageWithParts = MessageWithParts(
301+ info = UserMessage(
302+ id = tempId,
303+ sessionID = sessionId,
304+ time = soy.iko.opencode.data.model.TimeInfo(created = timestamp),
305+ ),
306+ parts = listOf(TextPart(id = "$tempId-text", text = text)),
307+ )
308+
230 /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
309 /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
231 * live progress checklist above the composer. distinctUntilChanged so a per-token messages
310 * live progress checklist above the composer. distinctUntilChanged so a per-token messages
232 * emission that doesn't change the plan doesn't recompose the bar. */
311 * emission that doesn't change the plan doesn't recompose the bar. */
@@ -786,8 +865,10 @@ class ChatViewModel(
786 while (currentCoroutineContext().isActive) {
865 while (currentCoroutineContext().isActive) {
787 try {
866 try {
788 conn.events.state.collect { state ->
867 conn.events.state.collect { state ->
789− if ((state == EventStreamClient.ConnectionState.Disconnected ||
868+ val isDown = state == EventStreamClient.ConnectionState.Disconnected ||
790− state == EventStreamClient.ConnectionState.Failed) && _running.value) {
869+ state == EventStreamClient.ConnectionState.Failed ||
870+ state == EventStreamClient.ConnectionState.AuthFailed
871+ if (isDown && _running.value) {
791 _running.value = false
872 _running.value = false
792 }
873 }
793 }
874 }
@@ -863,6 +944,16 @@ class ChatViewModel(
863 suppressDraftPersist.set(true)
944 suppressDraftPersist.set(true)
864 _draft.value = ""
945 _draft.value = ""
865 }
946 }
947+ // Inject an optimistic user message so the outgoing prompt is visible immediately,
948+ // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
949+ // real UserMessage arrives. Only for text-bearing prompts (an image-only prompt has
950+ // no text to show).
951+ val optimisticId = if (trimmed.isNotEmpty()) "__optimistic_${System.nanoTime()}" else null
952+ if (optimisticId != null) {
953+ // Clear any prior failed optimistic entry with the same text (a retry re-sends).
954+ _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
955+ _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
956+ }
866 viewModelScope.launch {
957 viewModelScope.launch {
867 runCatchingCancellable {
958 runCatchingCancellable {
868 conn.repository.sendPrompt(
959 conn.repository.sendPrompt(
@@ -874,6 +965,13 @@ class ChatViewModel(
874 idempotencyKey = key,
965 idempotencyKey = key,
875 )
966 )
876 }.onFailure {
967 }.onFailure {
968+ // Mark the optimistic message as failed so the user sees a failure indicator
969+ // instead of a perpetual "sending" state.
970+ if (optimisticId != null) {
971+ _optimisticMessages.update { entries ->
972+ entries.map { if (it.tempId == optimisticId) it.copy(failed = true) else it }
973+ }
974+ }
877 suppressDraftPersist.set(false)
975 suppressDraftPersist.set(false)
878 _failedDraft.value = trimmed
976 _failedDraft.value = trimmed
879 failedIdempotencyKey = key
977 failedIdempotencyKey = key
@@ -1326,18 +1424,19 @@ class ChatViewModel(
1326 * already viewing the session, so a confirmation dialog guards the action instead. */
1424 * already viewing the session, so a confirmation dialog guards the action instead. */
1327 fun deleteSession() {
1425 fun deleteSession() {
1328 val conn = connection ?: return
1426 val conn = connection ?: return
1329− viewModelScope.launch {
1427+ // Schedule a deferred delete with an undo window (matching SessionListViewModel's
1330− runCatchingCancellable { conn.repository.deleteSession(sessionId) }
1428+ // pattern), so the user can undo from the session list after navigating back. The
1331− .onSuccess {
1429+ // actual REST delete runs after undoDeleteDelayMs; cancel via cancelSessionDelete.
1332− container.draftStore.remove(sessionId)
1430+ container.scheduleSessionDelete(
1333− container.attachmentDraftStore.remove(sessionId)
1431+ id = sessionId,
1334− container.messageCacheStore.remove(conn.profile.id, sessionId)
1432+ delayMs = NetworkConfig.undoDeleteDelayMs,
1335− container.outboxStore.removeForSession(sessionId)
1433+ onDeleted = { /* cleanup handled by container.scheduleSessionDelete */ },
1336− container.clearUnread(sessionId)
1337− _sessionDeleted.value = true
1338− }
1339− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1434+ onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1340− }
1435+ )
1436+ // Let the SessionListScreen show the undo snackbar for this externally-initiated delete.
1437+ container.emitExternalSessionUndo(sessionId)
1438+ // Navigate back immediately so the user lands on the session list where the Undo shows.
1439+ _sessionDeleted.value = true
1341 }
1440 }
1342
1441
1343 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1442 /** Reconnect to the most recently used server profile (used when the connection is gone). */
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+99−27
@@ -1,7 +1,7 @@
1package soy.iko.opencode.ui.chat
1package soy.iko.opencode.ui.chat
2
2
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4−import androidx.compose.foundation.combinedClickable
4+import androidx.compose.foundation.gestures.detectTapGestures
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.BoxWithConstraints
7import androidx.compose.foundation.layout.BoxWithConstraints
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.automirrored.filled.VolumeUp
17import androidx.compose.material.icons.filled.CallSplit
17import androidx.compose.material.icons.filled.CallSplit
18import androidx.compose.material.icons.filled.ContentCopy
18import androidx.compose.material.icons.filled.ContentCopy
19import androidx.compose.material.icons.filled.Edit
19import androidx.compose.material.icons.filled.Edit
20+import androidx.compose.material.icons.filled.Error
20import androidx.compose.material.icons.filled.FormatQuote
21import androidx.compose.material.icons.filled.FormatQuote
21import androidx.compose.material.icons.filled.MoreVert
22import androidx.compose.material.icons.filled.MoreVert
22import androidx.compose.material.icons.filled.Restore
23import androidx.compose.material.icons.filled.Restore
@@ -24,6 +25,7 @@ import androidx.compose.material.icons.filled.SmartToy
24import androidx.compose.material.icons.filled.StopCircle
25import androidx.compose.material.icons.filled.StopCircle
25import androidx.compose.material3.DropdownMenu
26import androidx.compose.material3.DropdownMenu
26import androidx.compose.material3.DropdownMenuItem
27import androidx.compose.material3.DropdownMenuItem
28+import androidx.compose.material3.CircularProgressIndicator
27import androidx.compose.material3.Icon
29import androidx.compose.material3.Icon
28import androidx.compose.material3.IconButton
30import androidx.compose.material3.IconButton
29import androidx.compose.material3.MaterialTheme
31import androidx.compose.material3.MaterialTheme
@@ -38,10 +40,10 @@ import androidx.compose.ui.Alignment
38import androidx.compose.ui.Modifier
40import androidx.compose.ui.Modifier
39import androidx.compose.ui.draw.clip
41import androidx.compose.ui.draw.clip
40import androidx.compose.ui.hapticfeedback.HapticFeedbackType
42import androidx.compose.ui.hapticfeedback.HapticFeedbackType
43+import androidx.compose.ui.input.pointer.pointerInput
41import androidx.compose.ui.platform.LocalContext
44import androidx.compose.ui.platform.LocalContext
42import androidx.compose.ui.platform.LocalHapticFeedback
45import androidx.compose.ui.platform.LocalHapticFeedback
43import androidx.compose.ui.res.stringResource
46import androidx.compose.ui.res.stringResource
44−import androidx.compose.ui.semantics.Role
45import androidx.compose.ui.unit.dp
47import androidx.compose.ui.unit.dp
46import soy.iko.opencode.R
48import soy.iko.opencode.R
47import soy.iko.opencode.data.model.AssistantMessage
49import soy.iko.opencode.data.model.AssistantMessage
@@ -72,6 +74,9 @@ fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String
72 ?: id
74 ?: id
73}
75}
74
76
77+/** Status of an outgoing message, shown as a small indicator on the user bubble. */
78+enum class MessageSendStatus { SENDING, FAILED }
79+
75/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
80/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
76@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
81@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
77@Composable
82@Composable
@@ -88,11 +93,13 @@ fun MessageBubble(
88 isSpeaking: Boolean = false,
93 isSpeaking: Boolean = false,
89 onQuote: ((String) -> Unit)? = null,
94 onQuote: ((String) -> Unit)? = null,
90 onBranch: ((String) -> Unit)? = null,
95 onBranch: ((String) -> Unit)? = null,
96+ sendStatus: MessageSendStatus? = null,
97+ isEdited: Boolean = false,
91) {
98) {
92 when (message.info) {
99 when (message.info) {
93− is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch)
100+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited)
94 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
101 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
95− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote)
102+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote, onBranch, isEdited)
96 }
103 }
97}
104}
98
105
@@ -265,14 +272,19 @@ private fun UserBubble(
265 onEdit: ((String) -> Unit)? = null,
272 onEdit: ((String) -> Unit)? = null,
266 onQuote: ((String) -> Unit)? = null,
273 onQuote: ((String) -> Unit)? = null,
267 onBranch: ((String) -> Unit)? = null,
274 onBranch: ((String) -> Unit)? = null,
275+ sendStatus: MessageSendStatus? = null,
276+ isEdited: Boolean = false,
268) {
277) {
269 val context = LocalContext.current
278 val context = LocalContext.current
270 val haptics = LocalHapticFeedback.current
279 val haptics = LocalHapticFeedback.current
280+ val density = androidx.compose.ui.platform.LocalDensity.current
271 val copyLabel = stringResource(R.string.copy)
281 val copyLabel = stringResource(R.string.copy)
272 val editLabel = stringResource(R.string.edit_message)
282 val editLabel = stringResource(R.string.edit_message)
273 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
283 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
274 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
284 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
285+ // The menu anchors at the actual touch point rather than a fixed corner.
275 var longPressMenu by remember { mutableStateOf(false) }
286 var longPressMenu by remember { mutableStateOf(false) }
287+ var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
276 // Collect text from all TextParts for copying, so a user can reuse/repost their
288 // Collect text from all TextParts for copying, so a user can reuse/repost their
277 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
289 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
278 val textToCopy = remember(message.parts) {
290 val textToCopy = remember(message.parts) {
@@ -305,21 +317,65 @@ private fun UserBubble(
305 }
317 }
306 // Footer row with timestamp + inline actions. Long-press opens a context menu
318 // Footer row with timestamp + inline actions. Long-press opens a context menu
307 // consolidating copy/quote/branch/revert — the conventional Android pattern — so
319 // consolidating copy/quote/branch/revert — the conventional Android pattern — so
308− // the actions are discoverable without spotting the 18dp inline icons.
320+ // the actions are discoverable without spotting the 18dp inline icons. Uses
321+ // pointerInput/detectTapGestures (not combinedClickable) so no misleading "Button"
322+ // semantics are announced to TalkBack for an area whose tap does nothing, and the
323+ // menu anchors at the actual long-press position instead of a fixed corner.
309 Box {
324 Box {
310 Row(
325 Row(
311− modifier = Modifier.fillMaxWidth().combinedClickable(
326+ modifier = Modifier
312− role = Role.Button,
327+ .fillMaxWidth()
313− onClick = {},
328+ .pointerInput(Unit) {
314− onLongClick = {
329+ detectTapGestures(onLongPress = { offset ->
315− haptics.performHapticFeedback(HapticFeedbackType.LongPress)
330+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
331+ longPressOffset = androidx.compose.ui.unit.DpOffset(
332+ with(density) { offset.x.toDp() },
333+ with(density) { offset.y.toDp() },
334+ )
316− longPressMenu = true
335+ longPressMenu = true
336+ })
317 },
337 },
318− ),
319 horizontalArrangement = Arrangement.SpaceBetween,
338 horizontalArrangement = Arrangement.SpaceBetween,
320 verticalAlignment = Alignment.CenterVertically,
339 verticalAlignment = Alignment.CenterVertically,
321 ) {
340 ) {
341+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
342+ when (sendStatus) {
343+ MessageSendStatus.SENDING -> {
344+ CircularProgressIndicator(
345+ Modifier.size(12.dp),
346+ strokeWidth = 1.5.dp,
347+ color = MaterialTheme.colorScheme.onSurfaceVariant,
348+ )
349+ Text(
350+ stringResource(R.string.message_sending),
351+ style = MaterialTheme.typography.labelSmall,
352+ color = MaterialTheme.colorScheme.onSurfaceVariant,
353+ )
354+ }
355+ MessageSendStatus.FAILED -> {
356+ Icon(
357+ Icons.Filled.Error,
358+ contentDescription = stringResource(R.string.message_send_failed),
359+ modifier = Modifier.size(12.dp),
360+ tint = MaterialTheme.colorScheme.error,
361+ )
362+ Text(
363+ stringResource(R.string.message_send_failed),
364+ style = MaterialTheme.typography.labelSmall,
365+ color = MaterialTheme.colorScheme.error,
366+ )
367+ }
368+ null -> {}
369+ }
370+ if (isEdited && sendStatus == null) {
371+ Text(
372+ stringResource(R.string.message_edited),
373+ style = MaterialTheme.typography.labelSmall,
374+ color = MaterialTheme.colorScheme.onSurfaceVariant,
375+ )
376+ }
322− MessageTimestampText(message.info)
377+ MessageTimestampText(message.info)
378+ }
323 Row(verticalAlignment = Alignment.CenterVertically) {
379 Row(verticalAlignment = Alignment.CenterVertically) {
324 // Edit affordance: reload this prompt into the composer, reverting the
380 // Edit affordance: reload this prompt into the composer, reverting the
325 // conversation to before it so a Send replaces it. Only when there's text
381 // conversation to before it so a Send replaces it. Only when there's text
@@ -371,7 +427,7 @@ private fun UserBubble(
371 MessageLongPressMenu(
427 MessageLongPressMenu(
372 expanded = longPressMenu,
428 expanded = longPressMenu,
373 onDismiss = { longPressMenu = false },
429 onDismiss = { longPressMenu = false },
374− offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
430+ offset = longPressOffset,
375 text = textToCopy,
431 text = textToCopy,
376 onCopy = onCopy,
432 onCopy = onCopy,
377 onQuote = onQuote,
433 onQuote = onQuote,
@@ -396,6 +452,8 @@ private fun AssistantBlock(
396 onSpeak: ((String) -> Unit)? = null,
452 onSpeak: ((String) -> Unit)? = null,
397 isSpeaking: Boolean = false,
453 isSpeaking: Boolean = false,
398 onQuote: ((String) -> Unit)? = null,
454 onQuote: ((String) -> Unit)? = null,
455+ onBranch: ((String) -> Unit)? = null,
456+ isEdited: Boolean = false,
399) {
457) {
400 Column(
458 Column(
401 modifier = modifier.fillMaxWidth(),
459 modifier = modifier.fillMaxWidth(),
@@ -465,6 +523,7 @@ private fun AssistantBlock(
465 }
523 }
466 // Long-press context menu state (see UserBubble for rationale).
524 // Long-press context menu state (see UserBubble for rationale).
467 var longPressMenu by remember { mutableStateOf(false) }
525 var longPressMenu by remember { mutableStateOf(false) }
526+ var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
468 // Collect text from all TextParts for copy/read-aloud. Memoized so a
527 // Collect text from all TextParts for copy/read-aloud. Memoized so a
469 // scroll-induced recomposition doesn't re-scan the parts list.
528 // scroll-induced recomposition doesn't re-scan the parts list.
470 val textToCopy = remember(message.parts) {
529 val textToCopy = remember(message.parts) {
@@ -475,6 +534,7 @@ private fun AssistantBlock(
475 }
534 }
476 val assistantHaptics = LocalHapticFeedback.current
535 val assistantHaptics = LocalHapticFeedback.current
477 val assistantContext = LocalContext.current
536 val assistantContext = LocalContext.current
537+ val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
478 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
538 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
479 {
539 {
480 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
540 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -483,14 +543,18 @@ private fun AssistantBlock(
483 }
543 }
484 Box {
544 Box {
485 Row(
545 Row(
486− modifier = Modifier.fillMaxWidth().combinedClickable(
546+ modifier = Modifier
487− role = Role.Button,
547+ .fillMaxWidth()
488− onClick = {},
548+ .pointerInput(Unit) {
489− onLongClick = {
549+ detectTapGestures(onLongPress = { offset ->
490− assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
550+ assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
551+ longPressOffset = androidx.compose.ui.unit.DpOffset(
552+ with(assistantDensity) { offset.x.toDp() },
553+ with(assistantDensity) { offset.y.toDp() },
554+ )
491− longPressMenu = true
555+ longPressMenu = true
556+ })
492 },
557 },
493− ),
494 horizontalArrangement = Arrangement.SpaceBetween,
558 horizontalArrangement = Arrangement.SpaceBetween,
495 verticalAlignment = Alignment.CenterVertically,
559 verticalAlignment = Alignment.CenterVertically,
496 ) {
560 ) {
@@ -502,6 +566,13 @@ private fun AssistantBlock(
502 color = MaterialTheme.colorScheme.onSurfaceVariant,
566 color = MaterialTheme.colorScheme.onSurfaceVariant,
503 )
567 )
504 }
568 }
569+ if (isEdited) {
570+ Text(
571+ stringResource(R.string.message_edited),
572+ style = MaterialTheme.typography.labelSmall,
573+ color = MaterialTheme.colorScheme.onSurfaceVariant,
574+ )
575+ }
505 MessageTimestampText(message.info)
576 MessageTimestampText(message.info)
506 }
577 }
507 AssistantActions(
578 AssistantActions(
@@ -510,16 +581,17 @@ private fun AssistantBlock(
510 onSpeak = onSpeak,
581 onSpeak = onSpeak,
511 onRevert = onRevert,
582 onRevert = onRevert,
512 onQuote = onQuote,
583 onQuote = onQuote,
584+ onBranch = onBranch,
513 )
585 )
514 }
586 }
515 MessageLongPressMenu(
587 MessageLongPressMenu(
516 expanded = longPressMenu,
588 expanded = longPressMenu,
517 onDismiss = { longPressMenu = false },
589 onDismiss = { longPressMenu = false },
518− offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
590+ offset = longPressOffset,
519 text = textToCopy,
591 text = textToCopy,
520 onCopy = onCopy,
592 onCopy = onCopy,
521 onQuote = onQuote,
593 onQuote = onQuote,
522− onBranch = null,
594+ onBranch = onBranch,
523 onRevert = onRevert,
595 onRevert = onRevert,
524 )
596 )
525 }
597 }
@@ -538,6 +610,7 @@ private fun AssistantActions(
538 onSpeak: ((String) -> Unit)?,
610 onSpeak: ((String) -> Unit)?,
539 onRevert: (() -> Unit)?,
611 onRevert: (() -> Unit)?,
540 onQuote: ((String) -> Unit)? = null,
612 onQuote: ((String) -> Unit)? = null,
613+ onBranch: ((String) -> Unit)? = null,
541) {
614) {
542 val context = LocalContext.current
615 val context = LocalContext.current
543 val haptics = LocalHapticFeedback.current
616 val haptics = LocalHapticFeedback.current
@@ -574,10 +647,9 @@ private fun AssistantActions(
574 )
647 )
575 }
648 }
576 }
649 }
577− // Assistant replies can be quoted into the composer (branching is offered only on
650+ // Assistant replies can be quoted into the composer or branched into a new session.
578− // user prompts, where seeding a new session with the prompt makes sense).
579− if (textToCopy != null && onQuote != null) {
651+ if (textToCopy != null && (onQuote != null || onBranch != null)) {
580− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = null)
652+ MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
581 }
653 }
582 }
654 }
583}
655}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+14−2
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Error
23import androidx.compose.material.icons.filled.ExpandLess
23import androidx.compose.material.icons.filled.ExpandLess
24import androidx.compose.material.icons.filled.ExpandMore
24import androidx.compose.material.icons.filled.ExpandMore
25import androidx.compose.material.icons.filled.Psychology
25import androidx.compose.material.icons.filled.Psychology
26+import androidx.compose.material.icons.filled.Schedule
26import androidx.compose.material3.CircularProgressIndicator
27import androidx.compose.material3.CircularProgressIndicator
27import androidx.compose.material3.Icon
28import androidx.compose.material3.Icon
28import androidx.compose.material3.IconButton
29import androidx.compose.material3.IconButton
@@ -80,7 +81,7 @@ import soy.iko.opencode.ui.components.looksLikeDiff
80import soy.iko.opencode.ui.components.showCopyToast
81import soy.iko.opencode.ui.components.showCopyToast
81import soy.iko.opencode.R
82import soy.iko.opencode.R
82
83
83−private const val COLLAPSED_LIMIT = 600
84+private const val COLLAPSED_LIMIT = 4000
84
85
85// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
86// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
86// resilient decoding) so this stays human-readable; constructed lazily and memoized.
87// resilient decoding) so this stays human-readable; constructed lazily and memoized.
@@ -454,7 +455,18 @@ private fun CollapsibleDetail(
454@Composable
455@Composable
455private fun ToolStatusIcon(state: ToolState) {
456private fun ToolStatusIcon(state: ToolState) {
456 when (state) {
457 when (state) {
457− is ToolPending, is ToolRunning -> {
458+ is ToolPending -> {
459+ // Queued, not yet executing: a static clock icon distinguishes it from an
460+ // actively-running tool (spinner) so the user can tell what's waiting.
461+ val label = stringResource(R.string.tool_queued)
462+ Icon(
463+ Icons.Filled.Schedule,
464+ contentDescription = label,
465+ modifier = Modifier.size(16.dp),
466+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
467+ )
468+ }
469+ is ToolRunning -> {
458 val label = stringResource(R.string.tool_running)
470 val label = stringResource(R.string.tool_running)
459 CircularProgressIndicator(
471 CircularProgressIndicator(
460 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
472 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+10−3
@@ -60,7 +60,8 @@ fun ConnectionBanner(
60 when (state) {
60 when (state) {
61 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
61 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
62 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
62 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
63− EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
63+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
64+ EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
64 EventStreamClient.ConnectionState.Connected -> null
65 EventStreamClient.ConnectionState.Connected -> null
65 }
66 }
66 }
67 }
@@ -79,7 +80,13 @@ fun ConnectionBanner(
79 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
80 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
80 // transient connecting/reconnecting states by switching to the error palette,
81 // transient connecting/reconnecting states by switching to the error palette,
81 // so the banner conveys urgency without relying on text alone.
82 // so the banner conveys urgency without relying on text alone.
82− val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed
83+ val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed ||
84+ state == EventStreamClient.ConnectionState.AuthFailed
85+ // Server-side failure (auth or endpoint) — distinct from offline. Used to gate the
86+ // inline Retry button, which only makes sense when the device is online and the server
87+ // rejected the connection (not when the device itself is offline).
88+ val isServerFailure = state == EventStreamClient.ConnectionState.Failed ||
89+ state == EventStreamClient.ConnectionState.AuthFailed
83 val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
90 val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
84 else MaterialTheme.colorScheme.tertiaryContainer
91 else MaterialTheme.colorScheme.tertiaryContainer
85 val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
92 val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
@@ -121,7 +128,7 @@ fun ConnectionBanner(
121 // leaving the screen. Hidden during transient states (the system is
128 // leaving the screen. Hidden during transient states (the system is
122 // already reconnecting), when offline (retry can't help), and when no
129 // already reconnecting), when offline (retry can't help), and when no
123 // callback is wired (callers that don't have a reconnect path).
130 // callback is wired (callers that don't have a reconnect path).
124− if (state == EventStreamClient.ConnectionState.Failed && !isOffline && onRetry != null) {
131+ if (isServerFailure && !isOffline && onRetry != null) {
125 TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
132 TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
126 Text(stringResource(R.string.retry_now), color = onContainer)
133 Text(stringResource(R.string.retry_now), color = onContainer)
127 }
134 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+100−124
@@ -217,73 +217,15 @@ fun ServerListScreen(
217 vm.connect(profile, onConnected)
217 vm.connect(profile, onConnected)
218 },
218 },
219 ) {
219 ) {
220− Row(
220+ ServerCardContent(
221− modifier = Modifier.fillMaxWidth().padding(16.dp),
222− verticalAlignment = Alignment.CenterVertically,
223− ) {
224− Column(modifier = Modifier.weight(1f)) {
225− Text(
226− profile.displayLabel,
221+ profile = profile,
227− style = MaterialTheme.typography.titleMedium,
222+ isActive = true,
228− maxLines = 1,
223+ isConnecting = false,
229− overflow = TextOverflow.Ellipsis,
224+ onEdit = { onEditProfile(profile.id) },
230− )
225+ onDuplicate = { onDuplicateProfile(profile.id) },
231− Text(
226+ onPendingDelete = { pendingDeleteId = profile.id },
232− profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
233− style = MaterialTheme.typography.bodySmall,
234− color = MaterialTheme.colorScheme.onSurfaceVariant,
235− maxLines = 1,
236− overflow = TextOverflow.Ellipsis,
237− )
238− Text(
239− stringResource(R.string.connected),
240− style = MaterialTheme.typography.labelSmall,
241− color = MaterialTheme.colorScheme.primary,
242− )
243− LastUsedText(profile.lastUsed)
244− }
245− Icon(
246− Icons.Filled.CheckCircle,
247− contentDescription = stringResource(R.string.connected),
248− tint = MaterialTheme.colorScheme.primary,
249− modifier = Modifier.size(22.dp),
250 )
227 )
251− // Overflow menu consolidates edit/duplicate/delete so the
252− // active row shows a single chevron instead of three icons.
253− // Keep edit available on the active server so credentials can
254− // be fixed without disconnecting first. Delete is also offered:
255− // the confirmation dialog warns that removing the active server
256− // also disconnects (remove_server_active_text), and the
257− // ViewModel disconnects immediately on confirm.
258− var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
259− val editLabel = stringResource(R.string.edit)
260− val duplicateLabel = stringResource(R.string.duplicate_server)
261− val removeLabel = stringResource(R.string.remove)
262− val moreLabel = stringResource(R.string.more)
263− Box {
264− IconButton(onClick = { showRowMenu = true }) {
265− Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
266− }
267− DropdownMenu(
268− expanded = showRowMenu,
269− onDismissRequest = { showRowMenu = false },
270− ) {
271− DropdownMenuItem(
272− text = { Text(editLabel) },
273− onClick = { showRowMenu = false; onEditProfile(profile.id) },
274− )
275− DropdownMenuItem(
276− text = { Text(duplicateLabel) },
277− onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
278− )
279− DropdownMenuItem(
280− text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
281− onClick = { showRowMenu = false; pendingDeleteId = profile.id },
282− )
283− }
284− }
285 }
228 }
286− }
287 } else {
229 } else {
288 // Swipe end-to-start reveals a delete affordance and opens the
230 // Swipe end-to-start reveals a delete affordance and opens the
289 // same confirmation dialog as the trash icon (matching the
231 // same confirmation dialog as the trash icon (matching the
@@ -326,65 +268,14 @@ fun ServerListScreen(
326 vm.connect(profile, onConnected)
268 vm.connect(profile, onConnected)
327 },
269 },
328 ) {
270 ) {
329− Row(
271+ ServerCardContent(
330− modifier = Modifier.fillMaxWidth().padding(16.dp),
331− verticalAlignment = Alignment.CenterVertically,
332− ) {
333− Column(modifier = Modifier.weight(1f)) {
334− Text(
335− profile.displayLabel,
272+ profile = profile,
336− style = MaterialTheme.typography.titleMedium,
273+ isActive = false,
337− maxLines = 1,
338− overflow = TextOverflow.Ellipsis,
339− )
340− Text(
341− profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
342− style = MaterialTheme.typography.bodySmall,
343− color = MaterialTheme.colorScheme.onSurfaceVariant,
344− maxLines = 1,
345− overflow = TextOverflow.Ellipsis,
346− )
347− LastUsedText(profile.lastUsed)
348− }
349− if (connectingId == profile.id) {
274+ isConnecting = connectingId == profile.id,
350− val connectingLabel = stringResource(R.string.connecting)
351− CircularProgressIndicator(
352− modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
353− )
354− } else {
355− // Overflow menu replaces the three inline edit/duplicate/
356− // delete icons so a long server list looks less busy.
357− // Swipe-to-delete remains the gesture path; the overflow
358− // is the discovery path (matching the session cards).
359− var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
360− val editLabel = stringResource(R.string.edit)
361− val duplicateLabel = stringResource(R.string.duplicate_server)
362− val removeLabel = stringResource(R.string.remove)
363− val moreLabel = stringResource(R.string.more)
364− Box {
365− IconButton(onClick = { showRowMenu = true }) {
366− Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
367− }
368− DropdownMenu(
369− expanded = showRowMenu,
370− onDismissRequest = { showRowMenu = false },
371− ) {
372− DropdownMenuItem(
373− text = { Text(editLabel) },
374− onClick = { showRowMenu = false; onEditProfile(profile.id) },
275+ onEdit = { onEditProfile(profile.id) },
375− )
376− DropdownMenuItem(
377− text = { Text(duplicateLabel) },
378− onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
276+ onDuplicate = { onDuplicateProfile(profile.id) },
379− )
380− DropdownMenuItem(
381− text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
382− onClick = { showRowMenu = false; pendingDeleteId = profile.id },
277+ onPendingDelete = { pendingDeleteId = profile.id },
383− )
278+ )
384− }
385− }
386− }
387− }
388 }
279 }
389 }
280 }
390 }
281 }
@@ -422,6 +313,91 @@ fun ServerListScreen(
422 }
313 }
423}
314}
424
315
316+/** Shared content for a server profile card — used by both the active-row (plain Card)
317+ * and non-active-row (SwipeToDismissBox) branches, eliminating ~60 lines of duplication.
318+ * Shows the label, URL, optional "Connected" label, last-used time, and a trailing area
319+ * with a check icon (active), connecting spinner, or overflow menu. */
320+@Composable
321+private fun ServerCardContent(
322+ profile: ServerProfile,
323+ isActive: Boolean,
324+ isConnecting: Boolean,
325+ onEdit: () -> Unit,
326+ onDuplicate: () -> Unit,
327+ onPendingDelete: () -> Unit,
328+) {
329+ Row(
330+ modifier = Modifier.fillMaxWidth().padding(16.dp),
331+ verticalAlignment = Alignment.CenterVertically,
332+ ) {
333+ Column(modifier = Modifier.weight(1f)) {
334+ Text(
335+ profile.displayLabel,
336+ style = MaterialTheme.typography.titleMedium,
337+ maxLines = 1,
338+ overflow = TextOverflow.Ellipsis,
339+ )
340+ Text(
341+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
342+ style = MaterialTheme.typography.bodySmall,
343+ color = MaterialTheme.colorScheme.onSurfaceVariant,
344+ maxLines = 1,
345+ overflow = TextOverflow.Ellipsis,
346+ )
347+ if (isActive) {
348+ Text(
349+ stringResource(R.string.connected),
350+ style = MaterialTheme.typography.labelSmall,
351+ color = MaterialTheme.colorScheme.primary,
352+ )
353+ }
354+ LastUsedText(profile.lastUsed)
355+ }
356+ if (isActive) {
357+ Icon(
358+ Icons.Filled.CheckCircle,
359+ contentDescription = stringResource(R.string.connected),
360+ tint = MaterialTheme.colorScheme.primary,
361+ modifier = Modifier.size(22.dp),
362+ )
363+ }
364+ if (isConnecting) {
365+ val connectingLabel = stringResource(R.string.connecting)
366+ CircularProgressIndicator(
367+ modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
368+ )
369+ } else {
370+ var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
371+ val editLabel = stringResource(R.string.edit)
372+ val duplicateLabel = stringResource(R.string.duplicate_server)
373+ val removeLabel = stringResource(R.string.remove)
374+ val moreLabel = stringResource(R.string.more)
375+ Box {
376+ IconButton(onClick = { showRowMenu = true }) {
377+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
378+ }
379+ DropdownMenu(
380+ expanded = showRowMenu,
381+ onDismissRequest = { showRowMenu = false },
382+ ) {
383+ DropdownMenuItem(
384+ text = { Text(editLabel) },
385+ onClick = { showRowMenu = false; onEdit() },
386+ )
387+ DropdownMenuItem(
388+ text = { Text(duplicateLabel) },
389+ onClick = { showRowMenu = false; onDuplicate() },
390+ )
391+ DropdownMenuItem(
392+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
393+ onClick = { showRowMenu = false; onPendingDelete() },
394+ )
395+ }
396+ }
397+ }
398+ }
399+}
400+
425/** "Last used X ago" or "Not used yet" for a server profile, so the user can spot stale
401/** "Last used X ago" or "Not used yet" for a server profile, so the user can spot stale
426 * configs at a glance. Mirrors the relative-time formatting used elsewhere. */
402 * configs at a glance. Mirrors the relative-time formatting used elsewhere. */
427@Composable
403@Composable
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+12−3
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.combine
20import kotlinx.coroutines.flow.stateIn
20import kotlinx.coroutines.flow.stateIn
21import kotlinx.coroutines.flow.update
21import kotlinx.coroutines.flow.update
22import kotlinx.coroutines.launch
22import kotlinx.coroutines.launch
23+import kotlinx.coroutines.withTimeoutOrNull
23
24
24/** A transient error surfaced as a snackbar, optionally paired with the profile that
25/** A transient error surfaced as a snackbar, optionally paired with the profile that
25 * failed to connect so the snackbar can offer a Retry action. */
26 * failed to connect so the snackbar can offer a Retry action. */
@@ -66,9 +67,14 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
66 viewModelScope.launch {
67 viewModelScope.launch {
67 try {
68 try {
68 var conn: OpencodeConnection? = null
69 var conn: OpencodeConnection? = null
70+ // Wrap the connect + ping in a timeout so a hung/unresponsive server
71+ // doesn't leave the per-row spinner spinning indefinitely. The ping itself
72+ // also has a REST-level timeout, but the connection establishment path
73+ // (HttpClientFactory.create) can hang on DNS/TCP before any timeout applies.
69 val result = runCatchingCancellable {
74 val result = runCatchingCancellable {
70 conn = container.connect(profile)
75 conn = container.connect(profile)
71− conn!!.api.ping()
76+ withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) { conn!!.api.ping() }
77+ ?: throw java.net.SocketTimeoutException("Connect timed out")
72 }
78 }
73 result.onSuccess { onConnected() }
79 result.onSuccess { onConnected() }
74 .onFailure {
80 .onFailure {
@@ -86,10 +92,13 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
86
92
87 /** Retry connecting to the most recently used profile — the pull-to-refresh action on
93 /** Retry connecting to the most recently used profile — the pull-to-refresh action on
88 * the server list. Gives the user a familiar gesture to retry a failed auto-connect
94 * the server list. Gives the user a familiar gesture to retry a failed auto-connect
89− * instead of having to tap a server card. */
95+ * instead of having to tap a server card. Targets the profile with the highest
96+ * lastUsed timestamp (the most natural "retry the one I was just on"), falling back
97+ * to the first profile if none has been used yet. */
90 fun refresh(onConnected: () -> Unit) {
98 fun refresh(onConnected: () -> Unit) {
91 if (_connecting.value != null || container.reconnecting.value) return
99 if (_connecting.value != null || container.reconnecting.value) return
92− val target = profiles.value.firstOrNull() ?: return
100+ val target = profiles.value.maxByOrNull { it.lastUsed }
101+ ?: profiles.value.firstOrNull() ?: return
93 connect(target, onConnected)
102 connect(target, onConnected)
94 }
103 }
95
104
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+2−1
@@ -91,6 +91,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
91import androidx.lifecycle.viewmodel.compose.viewModel
91import androidx.lifecycle.viewmodel.compose.viewModel
92import kotlinx.coroutines.coroutineScope
92import kotlinx.coroutines.coroutineScope
93import kotlinx.coroutines.flow.collectLatest
93import kotlinx.coroutines.flow.collectLatest
94+import kotlinx.coroutines.flow.merge
94import kotlinx.coroutines.delay
95import kotlinx.coroutines.delay
95import kotlinx.coroutines.launch
96import kotlinx.coroutines.launch
96import soy.iko.opencode.data.model.ServerProfile
97import soy.iko.opencode.data.model.ServerProfile
@@ -205,7 +206,7 @@ fun SessionListScreen(
205 // it via undoDelete cancels the timer and restores the row (no-op if it already
206 // it via undoDelete cancels the timer and restores the row (no-op if it already
206 // fired), so no session is silently deleted without a chance to undo.
207 // fired), so no session is silently deleted without a chance to undo.
207 var pending: String? = null
208 var pending: String? = null
208− vm.undoEvents.collectLatest { sessionId ->
209+ merge(vm.undoEvents, container.externalSessionUndoEvents).collectLatest { sessionId ->
209 pending?.let { vm.undoDelete(it) }
210 pending?.let { vm.undoDelete(it) }
210 pending = sessionId
211 pending = sessionId
211 coroutineScope {
212 coroutineScope {
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+6−3
@@ -372,7 +372,8 @@ fun SettingsScreen(
372 val stateText = when (connectionState) {
372 val stateText = when (connectionState) {
373 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
373 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
374 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
374 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
375− EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
375+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
376+ EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
376 EventStreamClient.ConnectionState.Connected -> null
377 EventStreamClient.ConnectionState.Connected -> null
377 }
378 }
378 stateText?.let {
379 stateText?.let {
@@ -380,14 +381,16 @@ fun SettingsScreen(
380 modifier = Modifier.padding(top = 4.dp),
381 modifier = Modifier.padding(top = 4.dp),
381 verticalAlignment = Alignment.CenterVertically,
382 verticalAlignment = Alignment.CenterVertically,
382 ) {
383 ) {
384+ val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
385+ connectionState == EventStreamClient.ConnectionState.AuthFailed
383 Text(
386 Text(
384 it,
387 it,
385 style = MaterialTheme.typography.bodySmall,
388 style = MaterialTheme.typography.bodySmall,
386− color = if (connectionState == EventStreamClient.ConnectionState.Failed)
389+ color = if (isHardFailure)
387 MaterialTheme.colorScheme.error
390 MaterialTheme.colorScheme.error
388 else MaterialTheme.colorScheme.onSurfaceVariant,
391 else MaterialTheme.colorScheme.onSurfaceVariant,
389 )
392 )
390− if (connectionState == EventStreamClient.ConnectionState.Failed) {
393+ if (isHardFailure) {
391 TextButton(
394 TextButton(
392 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
395 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
393 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
396 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
app/src/main/res/values-es/strings.xml+21
@@ -38,6 +38,7 @@
38 <string name="connecting">Conectando…</string>
38 <string name="connecting">Conectando…</string>
39 <string name="reconnecting">Reconectando…</string>
39 <string name="reconnecting">Reconectando…</string>
40 <string name="connection_failed">Error de conexión — comprueba las credenciales</string>
40 <string name="connection_failed">Error de conexión — comprueba las credenciales</string>
41+ <string name="connection_failed_endpoint">Error de conexión — comprueba la URL y versión del servidor</string>
41 <string name="reconnect">Reconectar</string>
42 <string name="reconnect">Reconectar</string>
42 <string name="not_connected">Sin conexión.</string>
43 <string name="not_connected">Sin conexión.</string>
43 <string name="no_servers_to_reconnect">No hay ningún servidor guardado al que reconectar.</string>
44 <string name="no_servers_to_reconnect">No hay ningún servidor guardado al que reconectar.</string>
@@ -159,6 +160,7 @@
159 <string name="tool_completed">Completado</string>
160 <string name="tool_completed">Completado</string>
160 <string name="tool_error">Error</string>
161 <string name="tool_error">Error</string>
161 <string name="tool_running">En ejecución</string>
162 <string name="tool_running">En ejecución</string>
163+ <string name="tool_queued">En cola</string>
162 <string name="tool_unknown">Estado de herramienta desconocido</string>
164 <string name="tool_unknown">Estado de herramienta desconocido</string>
163 <string name="state_expanded">Expandido</string>
165 <string name="state_expanded">Expandido</string>
164 <string name="state_collapsed">Contraído</string>
166 <string name="state_collapsed">Contraído</string>
@@ -260,8 +262,16 @@
260 <string name="notif_action_always">Siempre</string>
262 <string name="notif_action_always">Siempre</string>
261 <string name="notif_action_reject">Rechazar</string>
263 <string name="notif_action_reject">Rechazar</string>
262 <string name="notif_action_reply">Responder</string>
264 <string name="notif_action_reply">Responder</string>
265+ <string name="notif_action_stop">Detener</string>
266+ <string name="notif_action_mark_read">Marcar leído</string>
263 <string name="notif_reply_hint">Envía un seguimiento…</string>
267 <string name="notif_reply_hint">Envía un seguimiento…</string>
264 <string name="notif_reply_sent">Enviado</string>
268 <string name="notif_reply_sent">Enviado</string>
269+ <string name="notif_running_progress">Paso %1$d de %2$d</string>
270+ <string name="notif_fg_timeout_text">La tarea larga podría pausarse en segundo plano</string>
271+ <string name="notif_rationale_title">¿Activar notificaciones?</string>
272+ <string name="notif_rationale_text">opencode usa notificaciones para avisarte cuando termina una ejecución, cuando un agente necesita aprobación, o cuando ocurre un error. Puedes ajustarlo luego en Ajustes.</string>
273+ <string name="notif_rationale_allow">Permitir</string>
274+ <string name="notif_rationale_skip">Omitir</string>
265 <string name="notif_reply_failed_title">Respuesta no enviada</string>
275 <string name="notif_reply_failed_title">Respuesta no enviada</string>
266 <string name="notif_reply_failed_text">No se pudo poner en cola tu seguimiento. Toca para abrir la sesión e inténtalo de nuevo.</string>
276 <string name="notif_reply_failed_text">No se pudo poner en cola tu seguimiento. Toca para abrir la sesión e inténtalo de nuevo.</string>
267
277
@@ -543,4 +553,15 @@
543 <item quantity="one">%1$d resultado</item>
553 <item quantity="one">%1$d resultado</item>
544 <item quantity="other">%1$d resultados</item>
554 <item quantity="other">%1$d resultados</item>
545 </plurals>
555 </plurals>
556+
557+ <!-- UI/UX batch 2: message status, crash prompt, server connection -->
558+ <string name="message_sending">Enviando…</string>
559+ <string name="message_send_failed">Error al enviar</string>
560+ <string name="message_edited">editado</string>
561+ <string name="crash_last_run_title">opencode se cerró la última vez</string>
562+ <string name="crash_last_run_text">Ocurrió un error inesperado durante la sesión anterior.</string>
563+ <string name="crash_last_run_view">Ver informe</string>
564+ <string name="crash_last_run_dismiss">Descartar</string>
565+ <string name="connecting_to">Conectando a %1$s…</string>
566+ <string name="scroll_to_top">Ir arriba</string>
546</resources>
567</resources>
app/src/main/res/values-night/colors.xmlnew file+6
@@ -0,0 +1,6 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- A darker splash background at night so the cold-start flash isn't a bright rectangle. -->
4+ <color name="splash_background">#1A1B26</color>
5+ <color name="ic_launcher_background">#34548A</color>
6+</resources>
app/src/main/res/values/strings.xml+21
@@ -38,6 +38,7 @@
38 <string name="connecting">Connecting…</string>
38 <string name="connecting">Connecting…</string>
39 <string name="reconnecting">Reconnecting…</string>
39 <string name="reconnecting">Reconnecting…</string>
40 <string name="connection_failed">Connection failed — check credentials</string>
40 <string name="connection_failed">Connection failed — check credentials</string>
41+ <string name="connection_failed_endpoint">Connection failed — check the server URL and version</string>
41 <string name="reconnect">Reconnect</string>
42 <string name="reconnect">Reconnect</string>
42 <string name="not_connected">Not connected.</string>
43 <string name="not_connected">Not connected.</string>
43 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
44 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
@@ -159,6 +160,7 @@
159 <string name="tool_completed">Completed</string>
160 <string name="tool_completed">Completed</string>
160 <string name="tool_error">Error</string>
161 <string name="tool_error">Error</string>
161 <string name="tool_running">Running</string>
162 <string name="tool_running">Running</string>
163+ <string name="tool_queued">Queued</string>
162 <string name="tool_unknown">Unknown tool state</string>
164 <string name="tool_unknown">Unknown tool state</string>
163 <string name="state_expanded">Expanded</string>
165 <string name="state_expanded">Expanded</string>
164 <string name="state_collapsed">Collapsed</string>
166 <string name="state_collapsed">Collapsed</string>
@@ -260,8 +262,16 @@
260 <string name="notif_action_always">Always</string>
262 <string name="notif_action_always">Always</string>
261 <string name="notif_action_reject">Reject</string>
263 <string name="notif_action_reject">Reject</string>
262 <string name="notif_action_reply">Reply</string>
264 <string name="notif_action_reply">Reply</string>
265+ <string name="notif_action_stop">Stop</string>
266+ <string name="notif_action_mark_read">Mark read</string>
263 <string name="notif_reply_hint">Send a follow-up…</string>
267 <string name="notif_reply_hint">Send a follow-up…</string>
264 <string name="notif_reply_sent">Sent</string>
268 <string name="notif_reply_sent">Sent</string>
269+ <string name="notif_running_progress">Step %1$d of %2$d</string>
270+ <string name="notif_fg_timeout_text">Long-running task may be paused in the background</string>
271+ <string name="notif_rationale_title">Enable notifications?</string>
272+ <string name="notif_rationale_text">opencode uses notifications to alert you when a run finishes, when an agent needs approval, or when a run fails. You can adjust this later in Settings.</string>
273+ <string name="notif_rationale_allow">Allow</string>
274+ <string name="notif_rationale_skip">Skip</string>
265 <string name="notif_reply_failed_title">Reply not sent</string>
275 <string name="notif_reply_failed_title">Reply not sent</string>
266 <string name="notif_reply_failed_text">Your follow-up couldn\'t be queued. Tap to open the session and try again.</string>
276 <string name="notif_reply_failed_text">Your follow-up couldn\'t be queued. Tap to open the session and try again.</string>
267
277
@@ -545,4 +555,15 @@
545 <item quantity="one">%1$d result</item>
555 <item quantity="one">%1$d result</item>
546 <item quantity="other">%1$d results</item>
556 <item quantity="other">%1$d results</item>
547 </plurals>
557 </plurals>
558+
559+ <!-- UI/UX batch 2: message status, crash prompt, server connection -->
560+ <string name="message_sending">Sending…</string>
561+ <string name="message_send_failed">Failed to send</string>
562+ <string name="message_edited">edited</string>
563+ <string name="crash_last_run_title">opencode crashed last time</string>
564+ <string name="crash_last_run_text">An unexpected error occurred during the previous session.</string>
565+ <string name="crash_last_run_view">View report</string>
566+ <string name="crash_last_run_dismiss">Dismiss</string>
567+ <string name="connecting_to">Connecting to %1$s…</string>
568+ <string name="scroll_to_top">Scroll to top</string>
548</resources>
569</resources>
app/src/main/res/values/themes.xml+1
@@ -9,6 +9,7 @@
9 <!-- Shown by core-splashscreen on cold start, then swapped for the theme above. -->
9 <!-- Shown by core-splashscreen on cold start, then swapped for the theme above. -->
10 <style name="Theme.OpencodeAndroid.Splash" parent="Theme.SplashScreen">
10 <style name="Theme.OpencodeAndroid.Splash" parent="Theme.SplashScreen">
11 <item name="windowSplashScreenBackground">@color/splash_background</item>
11 <item name="windowSplashScreenBackground">@color/splash_background</item>
12+ <item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
12 <item name="postSplashScreenTheme">@style/Theme.OpencodeAndroid</item>
13 <item name="postSplashScreenTheme">@style/Theme.OpencodeAndroid</item>
13 </style>
14 </style>
14</resources>
15</resources>