git.iko.soy/opencode-android
on master help sign in
Implement 22 UI/UX improvements: long-press menu, image zoom, full-screen editor, AMOLED theme, session-scoped permissions, notification grouping, responsive pickers, mDNS timeout, reduced-motion, export metadata
commitdecfac539574cf7cc980defe97ce494ef10101bc authoriko <mail@iko.soy> dateFri 3 Jul 2026, 10:52 (3 months ago) parent066cded
29 files changed+673−96
app/src/main/java/soy/iko/opencode/MainActivity.kt+3−1 app/src/main/java/soy/iko/opencode/data/model/Permission.kt+9−1 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+17 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+1−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+16−1 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+13−5 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+30−2 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+4−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+81−21 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+32−5 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4−1 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+18 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+151−16 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+5−1 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+14−11 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+10−3 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+37 app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt+47 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+83−3 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+6 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+21−6 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+32−14 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+7−2 app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+2 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+2 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+15 app/src/main/res/values-es/strings.xml+5 app/src/main/res/values/strings.xml+5 app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+3−1
app/src/main/java/soy/iko/opencode/MainActivity.kt+3−1
@@ -90,12 +90,14 @@ class MainActivity : FragmentActivity() {
9090 val locked = appLock
9191 if (themePair != null && locked != null) {
9292 val (themeMode, dynamicColor) = themePair
93+ val amoled = themeMode == ThemeMode.AMOLED
9394 val dark = when (themeMode) {
9495 ThemeMode.SYSTEM -> isSystemInDarkTheme()
9596 ThemeMode.LIGHT -> false
9697 ThemeMode.DARK -> true
98+ ThemeMode.AMOLED -> true
9799 }
98− OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
100+ OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
99101 AppLockGate(enabled = locked) {
100102 OpencodeAppUi(container = container)
101103 }
app/src/main/java/soy/iko/opencode/data/model/Permission.kt+9−1
@@ -38,11 +38,19 @@ data class Permission(
3838 }
3939}
4040
41−/** Allowed values for the permission response body. */
41+/** Allowed values for the permission response body.
42+ *
43+ * [SESSION] is a client-side scope: it auto-responds [ONCE] to subsequent permission requests
44+ * whose (type, pattern) match a remembered set, without showing the dialog. The set lives in
45+ * [soy.iko.opencode.ui.chat.ChatViewModel] and is cleared on connection change. It is NOT sent
46+ * to the server (the server only knows `once`/`always`/`reject`); the VM maps it to [ONCE] on
47+ * the wire and tracks the grant locally. This gives users a middle ground between "allow once"
48+ * (re-prompts on every file read) and "always" (persists in the server config outliving the app). */
4249enum class PermissionResponse(val wire: String) {
4350 ONCE("once"),
4451 ALWAYS("always"),
4552 REJECT("reject"),
53+ SESSION("once"),
4654}
4755
4856/** Body for `POST /session/:id/permissions/:permissionID`. */
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+17
@@ -107,6 +107,13 @@ object NetworkConfig {
107107 /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
108108 const val maxInMemoryMessages = 500
109109
110+ // --- File viewer (FileViewScreen) ---
111+
112+ /** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
113+ * split once (not twice) into this many lines at most, so it can't hold two full line
114+ * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
115+ const val maxRenderedFileLines = 5000
116+
110117 // --- File browser (FileBrowserViewModel) ---
111118
112119 /** Debounce delay before firing a file-search request after the user stops typing. */
@@ -165,6 +172,16 @@ object NetworkConfig {
165172 /** Timeout for loading a profile from DataStore before giving up. */
166173 const val profileLoadTimeoutMs = 5_000L
167174
175+ /** How long mDNS discovery searches before showing a "no servers found" terminal state.
176+ * Discovery continues running in the background (a late server can still appear), but the
177+ * spinner is replaced with the no-results message so the user isn't left looking at an
178+ * infinite spinner on a network with no opencode servers. */
179+ const val nsdDiscoveryNoResultTimeoutMs = 8_000L
180+
181+ /** Timeout for a credential test (probe) before giving up, so a hung/unresponsive server
182+ * doesn't leave the "Testing…" spinner spinning indefinitely. */
183+ const val testCredentialsTimeoutMs = 15_000L
184+
168185 // --- SSE stream (EventStreamClient, AppContainer) ---
169186
170187 /** Delay before retrying the message-activity observer after a failure. */
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+1−1
@@ -9,7 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore
99import kotlinx.coroutines.flow.Flow
1010import kotlinx.coroutines.flow.map
1111
12−enum class ThemeMode { SYSTEM, LIGHT, DARK }
12+enum class ThemeMode { SYSTEM, LIGHT, DARK, AMOLED }
1313
1414private val Context.settingsDataStore by preferencesDataStore(name = "settings")
1515
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+16−1
@@ -316,7 +316,22 @@ open class AppContainer private constructor(
316316 .debounce(NetworkConfig.runForegroundDebounceMs)
317317 .distinctUntilChanged()
318318 .collect { active ->
319− if (active) RunForegroundService.start(ctx) else RunForegroundService.stop(ctx)
319+ if (active) {
320+ // When exactly one run is active, resolve its title so the notification
321+ // identifies which session is running. Multiple concurrent runs fall back
322+ // to the generic title (naming one would be misleading).
323+ val title = synchronized(activeRuns) {
324+ activeRuns.toList().singleOrNull()
325+ }?.let { sid ->
326+ runCatchingCancellable {
327+ activeConnection.value?.repository?.listSessions()
328+ ?.firstOrNull { it.id == sid }?.displayTitle
329+ }.getOrNull()
330+ }
331+ RunForegroundService.start(ctx, title)
332+ } else {
333+ RunForegroundService.stop(ctx)
334+ }
320335 }
321336 }
322337 }
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+13−5
@@ -26,7 +26,11 @@ import soy.iko.opencode.R
2626class RunForegroundService : Service() {
2727
2828 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
29− val notification = buildNotification()
29+ // The session title (when a single run is active) is passed via the intent extra so
30+ // the notification can identify which session is running, instead of a generic
31+ // "Agent is working…". Null/blank falls back to the generic title.
32+ val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
33+ val notification = buildNotification(sessionTitle)
3034 // startForeground can throw ForegroundServiceStartNotAllowedException on
3135 // Android 12+ if the app is in the background when the service starts. Wrap
3236 // it so a backgrounded start (e.g. the user navigates away at the wrong
@@ -50,7 +54,7 @@ class RunForegroundService : Service() {
5054 return START_NOT_STICKY
5155 }
5256
53− private fun buildNotification(): Notification {
57+ private fun buildNotification(sessionTitle: String?): Notification {
5458 // Tapping the notification opens the app so the user can see the running session.
5559 val openIntent = Intent(this, MainActivity::class.java).apply {
5660 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -59,10 +63,11 @@ class RunForegroundService : Service() {
5963 this, 0, openIntent,
6064 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
6165 )
66+ val contentText = sessionTitle ?: getString(R.string.notif_running_text)
6267 return NotificationCompat.Builder(this, NotificationChannels.STATUS)
6368 .setSmallIcon(R.drawable.ic_launcher_foreground)
6469 .setContentTitle(getString(R.string.notif_running_title))
65− .setContentText(getString(R.string.notif_running_text))
70+ .setContentText(contentText)
6671 .setContentIntent(pendingIntent)
6772 .setOngoing(true)
6873 // Show a live elapsed timer so the user can see how long the current run has
@@ -102,13 +107,16 @@ class RunForegroundService : Service() {
102107 companion object {
103108 private const val TAG = "RunForegroundService"
104109 private const val NOTIF_ID = 1
110+ const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
105111
106− fun start(context: Context) {
112+ fun start(context: Context, sessionTitle: String? = null) {
107113 // startForegroundService can throw ForegroundServiceStartNotAllowedException
108114 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
109115 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
110116 runCatching {
111− context.startForegroundService(Intent(context, RunForegroundService::class.java))
117+ val intent = Intent(context, RunForegroundService::class.java)
118+ sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
119+ context.startForegroundService(intent)
112120 }.onFailure {
113121 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
114122 }
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+30−2
@@ -137,10 +137,35 @@ object SessionNotifications {
137137 .addAction(replyAction)
138138 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
139139 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
140+ // Group notifications from the same server so multiple completions collapse into
141+ // a stackable group on Android 7+ instead of filling the shade with individual entries.
142+ .setGroup(GROUP_COMPLETED)
140143 .build()
141144
142− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
143− .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
145+ runCatching {
146+ NotificationManagerCompat.from(context).notify(notifId, notification)
147+ // Post a summary notification so the group has a single top-level entry in the shade.
148+ postCompletedSummary(context)
149+ }.onFailure { Log.w(TAG, "Failed to post completion notification", it) }
150+ }
151+
152+ /** Post (or update) a summary notification for the completed-sessions group so multiple
153+ * completions collapse into one stackable entry instead of filling the shade. */
154+ @SuppressLint("MissingPermission")
155+ private fun postCompletedSummary(context: Context) {
156+ val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
157+ .setSmallIcon(R.drawable.ic_launcher_foreground)
158+ .setContentTitle(context.getString(R.string.notif_completed_title))
159+ .setContentText(context.getString(R.string.app_name))
160+ .setSmallIcon(R.drawable.ic_launcher_foreground)
161+ .setAutoCancel(true)
162+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
163+ .setGroup(GROUP_COMPLETED)
164+ .setGroupSummary(true)
165+ .build()
166+ runCatching {
167+ NotificationManagerCompat.from(context).notify(SUMMARY_COMPLETED_ID, summary)
168+ }.onFailure { Log.w(TAG, "Failed to post completion summary notification", it) }
144169 }
145170
146171 /** Post a heads-up notification for a permission request with Allow once / Always /
@@ -253,4 +278,7 @@ object SessionNotifications {
253278 fun cancelError(context: Context, sessionId: String) {
254279 NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
255280 }
281+
282+ private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
283+ private const val SUMMARY_COMPLETED_ID = -1
256284}
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+4−1
@@ -48,6 +48,7 @@ import androidx.compose.ui.semantics.semantics
4848import androidx.compose.ui.text.input.ImeAction
4949import androidx.compose.ui.unit.dp
5050import androidx.compose.foundation.layout.Box
51+import androidx.compose.foundation.layout.fillMaxHeight
5152import soy.iko.opencode.data.model.Agent
5253import soy.iko.opencode.data.network.NetworkConfig
5354import soy.iko.opencode.R
@@ -183,7 +184,9 @@ fun AgentPickerSheet(
183184 LaunchedEffect(Unit) {
184185 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
185186 }
186− LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
187+ // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
188+ // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
189+ LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
187190 if (defaultMatches) {
188191 item(key = "__default") {
189192 Column(
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+81−21
@@ -1,3 +1,5 @@
1+@file:Suppress("TooManyFunctions")
2+
13package soy.iko.opencode.ui.chat
24
35import android.app.Activity
@@ -37,6 +39,7 @@ import androidx.compose.material.icons.filled.AutoAwesome
3739import androidx.compose.material.icons.filled.Delete
3840import androidx.compose.material.icons.filled.Description
3941import androidx.compose.material.icons.filled.Edit
42+import androidx.compose.material.icons.filled.Expand
4043import androidx.compose.material.icons.filled.KeyboardArrowDown
4144import androidx.compose.material.icons.filled.Mic
4245import androidx.compose.material.icons.filled.MoreVert
@@ -44,6 +47,7 @@ import androidx.compose.material.icons.filled.PhotoCamera
4447import androidx.compose.material.icons.filled.PhotoLibrary
4548import androidx.compose.material.icons.filled.Refresh
4649import androidx.compose.material.icons.filled.Restore
50+import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
4751import androidx.compose.material.icons.filled.Schedule
4852import androidx.compose.material.icons.filled.Stop
4953import androidx.compose.material3.AlertDialog
@@ -1224,6 +1228,8 @@ private fun ChatInputBar(
12241228) {
12251229 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
12261230 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1231+ // Full-screen editor state: opened when the user wants more space for a long prompt.
1232+ var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
12271233 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
12281234 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
12291235 // A queued follow-up replaces the Stop button with a "queued" chip so the
@@ -1288,6 +1294,10 @@ private fun ChatInputBar(
12881294 IconButton(onClick = onVoice, enabled = enabled) {
12891295 Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
12901296 }
1297+ // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
1298+ IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
1299+ Icon(Icons.Filled.Expand, contentDescription = stringResource(R.string.expand_editor))
1300+ }
12911301 OutlinedTextField(
12921302 value = value,
12931303 onValueChange = { v ->
@@ -1344,26 +1354,15 @@ private fun ChatInputBar(
13441354 }
13451355 },
13461356 keyboardOptions = KeyboardOptions(
1347− // With send-on-Enter off, expose the platform default action so the soft
1348− // keyboard shows a newline key (this is a multi-line field) instead of a
1349− // Send action — otherwise the setting has no effect on soft keyboards and
1350− // there's no way to type a newline. Hardware Enter is handled separately.
1351− imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
1357+ // Always expose the platform default (newline) IME action so a soft-
1358+ // keyboard user can insert a newline even when "Send on Enter" is on —
1359+ // soft keyboards have no Shift key, so the hardware Shift+Enter escape
1360+ // hatch doesn't apply. The trailing Send button and the hardware Enter
1361+ // (onPreviewKeyEvent above) are the send affordances; the soft IME
1362+ // action is the newline affordance.
1363+ imeAction = ImeAction.Default,
13521364 capitalization = KeyboardCapitalization.Sentences,
13531365 ),
1354− keyboardActions = KeyboardActions(onSend = {
1355− if (enabled) {
1356− // During a run the Send IME action queues a follow-up, but only when
1357− // there's typed text — queuing "" would cancel an existing queued
1358− // follow-up (see the Queue button gate). When not running, an
1359− // attachment-only send is valid, so gate that path on hasContent.
1360− if (running) {
1361− if (value.isNotBlank()) onQueueFollowUp(value)
1362− } else if (hasContent) {
1363− onSend()
1364− }
1365− }
1366− }),
13671366 )
13681367 ComposerTrailingButton(
13691368 running = running,
@@ -1376,6 +1375,66 @@ private fun ChatInputBar(
13761375 )
13771376 }
13781377 }
1378+ if (showFullScreenEditor) {
1379+ FullScreenEditor(
1380+ value = value,
1381+ onValueChange = onValueChange,
1382+ onDismiss = { showFullScreenEditor = false },
1383+ onSend = { showFullScreenEditor = false; onSend() },
1384+ canSend = enabled && hasContent,
1385+ )
1386+ }
1387+ }
1388+}
1389+
1390+/** Full-screen editor dialog for composing long prompts. Gives the full viewport height to
1391+ * the text field (vs the inline 6-line cap) with a Send button and a collapse action. */
1392+@OptIn(ExperimentalMaterial3Api::class)
1393+@Composable
1394+private fun FullScreenEditor(
1395+ value: String,
1396+ onValueChange: (String) -> Unit,
1397+ onDismiss: () -> Unit,
1398+ onSend: () -> Unit,
1399+ canSend: Boolean,
1400+) {
1401+ androidx.compose.ui.window.Dialog(
1402+ onDismissRequest = onDismiss,
1403+ properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
1404+ ) {
1405+ Scaffold(
1406+ topBar = {
1407+ androidx.compose.material3.TopAppBar(
1408+ title = { Text(stringResource(R.string.message_placeholder)) },
1409+ navigationIcon = {
1410+ androidx.compose.material3.TextButton(onClick = onDismiss) {
1411+ Text(stringResource(R.string.collapse_editor))
1412+ }
1413+ },
1414+ )
1415+ },
1416+ bottomBar = {
1417+ Surface(tonalElevation = 3.dp) {
1418+ Row(
1419+ modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp),
1420+ horizontalArrangement = Arrangement.End,
1421+ ) {
1422+ androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
1423+ Text(stringResource(R.string.send))
1424+ }
1425+ }
1426+ }
1427+ },
1428+ ) { padding ->
1429+ OutlinedTextField(
1430+ value = value,
1431+ onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1432+ modifier = Modifier.fillMaxSize().padding(padding),
1433+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
1434+ maxLines = Int.MAX_VALUE,
1435+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
1436+ )
1437+ }
13791438 }
13801439}
13811440
@@ -1422,10 +1481,11 @@ private fun ComposerTrailingButton(
14221481 onClick = onQueue,
14231482 modifier = Modifier.testTag("queue_button"),
14241483 ) {
1425− // A clock/schedule icon (not Send) so it reads as "queue for later" and
1426− // isn't confused with the send action sitting right beside the Stop button.
1484+ // PlaylistAdd reads as "add to a queue/list" more clearly than a clock
1485+ // icon (which can be read as "snooze" or "later"), and isn't confused with
1486+ // the Send action sitting right beside the Stop button.
14271487 Icon(
1428− Icons.Filled.Schedule,
1488+ Icons.AutoMirrored.Filled.PlaylistAdd,
14291489 contentDescription = stringResource(R.string.queue_followup),
14301490 )
14311491 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+32−5
@@ -321,7 +321,24 @@ class ChatViewModel(
321321 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
322322 private val pendingPermissions = LinkedHashMap<String, Permission>()
323323
324+ // Session-scoped permission grants (Allow-for-this-session). Each entry is a (type, patternText)
325+ // pair the user approved with PermissionResponse.SESSION. Subsequent matching requests are
326+ // auto-responded ONCE without showing the dialog, so e.g. a user granting "read" for one path
327+ // isn't re-prompted for every other file read in the same conversation. Cleared on connection
328+ // change (see the activeConnection collector) so it never leaks across servers. The server only
329+ // knows once/always/reject; SESSION is a client-side scope that maps to ONCE on the wire.
330+ private val sessionAllowed = mutableSetOf<Pair<String, String>>()
331+
324332 private fun enqueuePermission(permission: Permission) {
333+ // Auto-respond to a permission whose (type, pattern) the user previously granted for this
334+ // session, instead of showing the dialog again. Resolved immediately (not via the queue) so
335+ // the tool run isn't paused waiting for a dialog the user will never see.
336+ val type = permission.type.orEmpty()
337+ val pattern = permission.patternText.orEmpty()
338+ if (type.isNotEmpty() && sessionAllowed.contains(type to pattern)) {
339+ respondPermission(permission, PermissionResponse.ONCE)
340+ return
341+ }
325342 pendingPermissions[permission.id] = permission
326343 _pendingPermission.value = pendingPermissions.values.lastOrNull()
327344 updatePermissionProgress()
@@ -600,11 +617,12 @@ class ChatViewModel(
600617 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
601618 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
602619 // returns with no connection) until a later reconnect happens to clear it.
603− _running.value = false
604− _aborting.value = false
605− clearPermissions()
606− sessionTitleJob?.cancel()
607− return@collectLatest
620+ _running.value = false
621+ _aborting.value = false
622+ clearPermissions()
623+ sessionAllowed.clear()
624+ sessionTitleJob?.cancel()
625+ return@collectLatest
608626 }
609627 _running.value = false
610628 // Allow the re-light below to recover a still-running run after this (re)connect;
@@ -612,6 +630,7 @@ class ChatViewModel(
612630 // won't spuriously re-light.
613631 runEndedByIdle = false
614632 clearPermissions()
633+ sessionAllowed.clear()
615634 _failedDraft.value = null
616635 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
617636 // user intent that is now persisted; wiping it on every (re)connect — which
@@ -1353,6 +1372,14 @@ class ChatViewModel(
13531372
13541373 fun respondPermission(permission: Permission, response: PermissionResponse) {
13551374 val conn = connection ?: return
1375+ // Record a session-scoped grant so subsequent matching requests are auto-answered.
1376+ // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1377+ // client-side set is what gives it the "for this session" semantics.
1378+ if (response == PermissionResponse.SESSION) {
1379+ val type = permission.type.orEmpty()
1380+ val pattern = permission.patternText.orEmpty()
1381+ if (type.isNotEmpty()) sessionAllowed.add(type to pattern)
1382+ }
13561383 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
13571384 // If the call fails we re-surface this request so the user can retry instead of being stuck
13581385 // with a dismissed dialog and a tool run that's still paused server-side.
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4−1
@@ -45,6 +45,8 @@ import androidx.compose.ui.text.input.ImeAction
4545import androidx.compose.ui.text.style.TextOverflow
4646import androidx.compose.ui.unit.dp
4747import androidx.compose.foundation.layout.Box
48+import androidx.compose.foundation.layout.fillMaxHeight
49+import androidx.compose.ui.platform.LocalConfiguration
4850import soy.iko.opencode.data.model.Command
4951import soy.iko.opencode.data.network.NetworkConfig
5052import soy.iko.opencode.R
@@ -144,7 +146,8 @@ fun CommandPickerSheet(
144146 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
145147 )
146148 } else {
147− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
149+ // Responsive height: half the viewport rather than a hardcoded 420dp cap.
150+ LazyColumn(modifier = Modifier.fillMaxHeight(0.5f)) {
148151 // distinctBy the name so duplicate command names from the server can't
149152 // crash the list with LazyColumn's "Key was already used".
150153 items(filtered.distinctBy { it.name }, key = { it.name }) { cmd ->
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+18
@@ -21,6 +21,7 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
2121 if (!title.isNullOrBlank()) {
2222 sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
2323 }
24+ appendMetadataHeader(sb, messages)
2425 for (message in messages) {
2526 val heading = when (message.info) {
2627 is UserMessage -> "## You"
@@ -50,6 +51,23 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
5051 return sb.toString().trimEnd()
5152}
5253
54+/** Append a metadata header (message counts + timestamp range) so a shared transcript has
55+ * context for support/debugging. Skipped when there are no messages. */
56+private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithParts>) {
57+ if (messages.isEmpty()) return
58+ val userCount = messages.count { it.info is UserMessage }
59+ val assistantCount = messages.count { it.info is AssistantMessage }
60+ val firstTime = messages.firstOrNull()?.info?.time?.created
61+ val lastTime = messages.lastOrNull()?.info?.time?.created
62+ sb.append("> ")
63+ sb.append("$userCount user · $assistantCount assistant message")
64+ if (userCount != 1) sb.append("s")
65+ if (firstTime != null && lastTime != null) {
66+ sb.append(" · $firstTime → $lastTime")
67+ }
68+ sb.append("\n\n---\n\n")
69+}
70+
5371/** Render a tool call as a compact, readable blockquote summary: the tool name, its
5472 * human-readable title (if any), and a truncated output/error. Keeps the exported
5573 * transcript useful when the agent's work (edits, command runs) is the substance. */
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+151−16
@@ -1,7 +1,9 @@
11package soy.iko.opencode.ui.chat
22
33import androidx.compose.foundation.background
4+import androidx.compose.foundation.combinedClickable
45import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
57import androidx.compose.foundation.layout.BoxWithConstraints
68import androidx.compose.foundation.layout.Column
79import androidx.compose.foundation.layout.Row
@@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Edit
1820import androidx.compose.material.icons.filled.FormatQuote
1921import androidx.compose.material.icons.filled.MoreVert
2022import androidx.compose.material.icons.filled.Restore
23+import androidx.compose.material.icons.filled.SmartToy
2124import androidx.compose.material.icons.filled.StopCircle
2225import androidx.compose.material3.DropdownMenu
2326import androidx.compose.material3.DropdownMenuItem
@@ -38,6 +41,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
3841import androidx.compose.ui.platform.LocalContext
3942import androidx.compose.ui.platform.LocalHapticFeedback
4043import androidx.compose.ui.res.stringResource
44+import androidx.compose.ui.semantics.Role
4145import androidx.compose.ui.unit.dp
4246import soy.iko.opencode.R
4347import soy.iko.opencode.data.model.AssistantMessage
@@ -69,6 +73,7 @@ fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String
6973}
7074
7175/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
76+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
7277@Composable
7378fun MessageBubble(
7479 message: MessageWithParts,
@@ -91,6 +96,54 @@ fun MessageBubble(
9196 }
9297}
9398
99+/** A long-press context menu consolidating all per-message actions (copy, quote, branch,
100+ * revert). Surfacing these via long-press matches the conventional Android pattern so the
101+ * actions are discoverable without spotting the small 18dp inline icon row. The menu is
102+ * anchored to the long-press position via [offset], and only items with non-null callbacks
103+ * (and text to act on) are shown. */
104+@Composable
105+private fun MessageLongPressMenu(
106+ expanded: Boolean,
107+ onDismiss: () -> Unit,
108+ offset: androidx.compose.ui.unit.DpOffset,
109+ text: String?,
110+ onCopy: (() -> Unit)?,
111+ onQuote: ((String) -> Unit)?,
112+ onBranch: ((String) -> Unit)?,
113+ onRevert: (() -> Unit)?,
114+) {
115+ DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
116+ if (text != null && onCopy != null) {
117+ DropdownMenuItem(
118+ leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
119+ text = { Text(stringResource(R.string.copy)) },
120+ onClick = { onDismiss(); onCopy() },
121+ )
122+ }
123+ if (text != null && onQuote != null) {
124+ DropdownMenuItem(
125+ leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
126+ text = { Text(stringResource(R.string.quote_reply)) },
127+ onClick = { onDismiss(); onQuote(text) },
128+ )
129+ }
130+ if (text != null && onBranch != null) {
131+ DropdownMenuItem(
132+ leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
133+ text = { Text(stringResource(R.string.branch_session)) },
134+ onClick = { onDismiss(); onBranch(text) },
135+ )
136+ }
137+ if (onRevert != null) {
138+ DropdownMenuItem(
139+ leadingIcon = { Icon(Icons.Filled.Restore, contentDescription = null) },
140+ text = { Text(stringResource(R.string.revert_to_here)) },
141+ onClick = { onDismiss(); onRevert() },
142+ )
143+ }
144+ }
145+}
146+
94147/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
95148 * Shown when at least one action is available and there's text to act on. Keeps the
96149 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
@@ -201,6 +254,7 @@ private fun UnknownMessageBlock(
201254 }
202255}
203256
257+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
204258@Composable
205259private fun UserBubble(
206260 message: MessageWithParts,
@@ -216,6 +270,9 @@ private fun UserBubble(
216270 val haptics = LocalHapticFeedback.current
217271 val copyLabel = stringResource(R.string.copy)
218272 val editLabel = stringResource(R.string.edit_message)
273+ // 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.
275+ var longPressMenu by remember { mutableStateOf(false) }
219276 // Collect text from all TextParts for copying, so a user can reuse/repost their
220277 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
221278 val textToCopy = remember(message.parts) {
@@ -224,6 +281,12 @@ private fun UserBubble(
224281 .joinToString("\n\n") { it.text }
225282 .takeIf { it.isNotBlank() }
226283 }
284+ val onCopy: (() -> Unit)? = textToCopy?.let { text ->
285+ {
286+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
287+ copyToClipboard(context, context.getString(R.string.clip_label_message), text)
288+ }
289+ }
227290 // Wrap-content bubble capped at the width fraction (end-aligned), so a one-word prompt is
228291 // snug instead of a wide empty box while long prompts still cap at the readable fraction.
229292 BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
@@ -240,8 +303,19 @@ private fun UserBubble(
240303 message.parts.forEachIndexed { index, part ->
241304 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
242305 }
306+ // Footer row with timestamp + inline actions. Long-press opens a context menu
307+ // consolidating copy/quote/branch/revert — the conventional Android pattern — so
308+ // the actions are discoverable without spotting the 18dp inline icons.
309+ Box {
243310 Row(
244− modifier = Modifier.fillMaxWidth(),
311+ modifier = Modifier.fillMaxWidth().combinedClickable(
312+ role = Role.Button,
313+ onClick = {},
314+ onLongClick = {
315+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
316+ longPressMenu = true
317+ },
318+ ),
245319 horizontalArrangement = Arrangement.SpaceBetween,
246320 verticalAlignment = Alignment.CenterVertically,
247321 ) {
@@ -294,10 +368,22 @@ private fun UserBubble(
294368 }
295369 }
296370 }
371+ MessageLongPressMenu(
372+ expanded = longPressMenu,
373+ onDismiss = { longPressMenu = false },
374+ offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
375+ text = textToCopy,
376+ onCopy = onCopy,
377+ onQuote = onQuote,
378+ onBranch = onBranch,
379+ onRevert = onRevert,
380+ )
381+ }
297382 }
298383 }
299384}
300385
386+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
301387@Composable
302388private fun AssistantBlock(
303389 message: MessageWithParts,
@@ -318,12 +404,32 @@ private fun AssistantBlock(
318404 val info = message.info
319405 if (info is AssistantMessage) {
320406 val label = modelLabel ?: info.modelID
321− if (label != null) {
322− Text(
323− label,
324− style = MaterialTheme.typography.labelSmall,
325− color = MaterialTheme.colorScheme.onSurfaceVariant,
326− )
407+ // Avatar + model label row: the robot icon gives the assistant a consistent visual
408+ // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
409+ // scroll the speaker is unambiguous without reading the label.
410+ Row(verticalAlignment = Alignment.CenterVertically) {
411+ Box(
412+ modifier = Modifier
413+ .size(20.dp)
414+ .clip(MaterialTheme.shapes.extraSmall)
415+ .background(MaterialTheme.colorScheme.secondaryContainer),
416+ contentAlignment = Alignment.Center,
417+ ) {
418+ Icon(
419+ Icons.Filled.SmartToy,
420+ contentDescription = null,
421+ modifier = Modifier.size(14.dp),
422+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
423+ )
424+ }
425+ if (label != null) {
426+ Text(
427+ label,
428+ style = MaterialTheme.typography.labelSmall,
429+ color = MaterialTheme.colorScheme.onSurfaceVariant,
430+ modifier = Modifier.padding(start = 6.dp),
431+ )
432+ }
327433 }
328434 }
329435 message.parts.forEachIndexed { index, part ->
@@ -357,8 +463,34 @@ private fun AssistantBlock(
357463 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
358464 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
359465 }
466+ // Long-press context menu state (see UserBubble for rationale).
467+ var longPressMenu by remember { mutableStateOf(false) }
468+ // Collect text from all TextParts for copy/read-aloud. Memoized so a
469+ // scroll-induced recomposition doesn't re-scan the parts list.
470+ val textToCopy = remember(message.parts) {
471+ message.parts
472+ .filterIsInstance<TextPart>()
473+ .joinToString("\n\n") { it.text }
474+ .takeIf { it.isNotBlank() }
475+ }
476+ val assistantHaptics = LocalHapticFeedback.current
477+ val assistantContext = LocalContext.current
478+ val onCopy: (() -> Unit)? = textToCopy?.let { text ->
479+ {
480+ assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
481+ copyToClipboard(assistantContext, assistantContext.getString(R.string.clip_label_message), text)
482+ }
483+ }
484+ Box {
360485 Row(
361− modifier = Modifier.fillMaxWidth(),
486+ modifier = Modifier.fillMaxWidth().combinedClickable(
487+ role = Role.Button,
488+ onClick = {},
489+ onLongClick = {
490+ assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
491+ longPressMenu = true
492+ },
493+ ),
362494 horizontalArrangement = Arrangement.SpaceBetween,
363495 verticalAlignment = Alignment.CenterVertically,
364496 ) {
@@ -372,14 +504,6 @@ private fun AssistantBlock(
372504 }
373505 MessageTimestampText(message.info)
374506 }
375− // Collect text from all TextParts for copy/read-aloud. Memoized so a
376− // scroll-induced recomposition doesn't re-scan the parts list.
377− val textToCopy = remember(message.parts) {
378− message.parts
379− .filterIsInstance<TextPart>()
380− .joinToString("\n\n") { it.text }
381− .takeIf { it.isNotBlank() }
382− }
383507 AssistantActions(
384508 textToCopy = textToCopy,
385509 isSpeaking = isSpeaking,
@@ -388,6 +512,17 @@ private fun AssistantBlock(
388512 onQuote = onQuote,
389513 )
390514 }
515+ MessageLongPressMenu(
516+ expanded = longPressMenu,
517+ onDismiss = { longPressMenu = false },
518+ offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
519+ text = textToCopy,
520+ onCopy = onCopy,
521+ onQuote = onQuote,
522+ onBranch = null,
523+ onRevert = onRevert,
524+ )
525+ }
391526 } else {
392527 MessageTimestampText(message.info)
393528 }
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+5−1
@@ -48,6 +48,8 @@ import androidx.compose.ui.semantics.semantics
4848import androidx.compose.ui.text.input.ImeAction
4949import androidx.compose.ui.unit.dp
5050import androidx.compose.foundation.layout.Box
51+import androidx.compose.foundation.layout.fillMaxHeight
52+import androidx.compose.ui.platform.LocalConfiguration
5153import soy.iko.opencode.data.model.ModelOption
5254import soy.iko.opencode.data.network.NetworkConfig
5355import soy.iko.opencode.R
@@ -177,7 +179,9 @@ fun ModelPickerSheet(
177179 LaunchedEffect(Unit) {
178180 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
179181 }
180− LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
182+ // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
183+ // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
184+ LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
181185 grouped.forEach { (providerID, opts) ->
182186 item(key = "header_$providerID") {
183187 Text(
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+14−11
@@ -225,19 +225,22 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
225225 color = MaterialTheme.colorScheme.onSurfaceVariant,
226226 )
227227 }
228− TextButton(
229− onClick = {
230− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
231− copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
232− },
233− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
234− modifier = Modifier.semantics(mergeDescendants = true) {},
235− ) {
236− Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
237− Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
238− }
239228 }
240229 }
230+ // Copy affordance lives outside AnimatedVisibility so it's reachable when the
231+ // block is collapsed — previously the copy button was inside the expanded content,
232+ // leaving a collapsed reasoning block with no copy affordance at all.
233+ TextButton(
234+ onClick = {
235+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
236+ copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
237+ },
238+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
239+ modifier = Modifier.semantics(mergeDescendants = true) {},
240+ ) {
241+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
242+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
243+ }
241244 }
242245}
243246
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+10−3
@@ -130,6 +130,13 @@ fun PermissionDialog(
130130 },
131131 modifier = Modifier.fillMaxWidth(),
132132 ) { Text(stringResource(R.string.allow_once)) }
133+ OutlinedButton(
134+ onClick = {
135+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
136+ respond(PermissionResponse.SESSION)
137+ },
138+ modifier = Modifier.fillMaxWidth(),
139+ ) { Text(stringResource(R.string.allow_for_session)) }
133140 OutlinedButton(
134141 onClick = {
135142 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
@@ -138,9 +145,9 @@ fun PermissionDialog(
138145 modifier = Modifier.fillMaxWidth(),
139146 ) { Text(stringResource(R.string.always_allow)) }
140147 // Reject is an OutlinedButton (not a low-prominence TextButton) in the
141− // same column so the three actions are visually balanced — the prior
142− // layout buried Reject as a small dismiss-button, nudging users toward
143− // granting. Reject uses the error color to signal its consequence.
148+ // same column so the actions are visually balanced — the prior layout buried
149+ // Reject as a small dismiss-button, nudging users toward granting. Reject
150+ // uses the error color to signal its consequence.
144151 OutlinedButton(
145152 onClick = {
146153 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+37
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
44import androidx.compose.animation.fadeIn
55import androidx.compose.animation.fadeOut
66import androidx.compose.foundation.layout.Arrangement
7+import androidx.compose.foundation.layout.BoxScope
78import androidx.compose.foundation.layout.Row
89import androidx.compose.foundation.layout.fillMaxWidth
910import androidx.compose.foundation.layout.padding
@@ -14,6 +15,8 @@ import androidx.compose.material3.Surface
1415import androidx.compose.material3.Text
1516import androidx.compose.material3.TextButton
1617import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.getValue
19+import androidx.compose.runtime.produceState
1720import androidx.compose.runtime.remember
1821import androidx.compose.runtime.mutableStateOf
1922import androidx.compose.ui.Alignment
@@ -24,8 +27,13 @@ import androidx.compose.ui.semantics.liveRegion
2427import androidx.compose.ui.semantics.LiveRegionMode
2528import androidx.compose.ui.semantics.semantics
2629import androidx.compose.ui.unit.dp
30+import androidx.lifecycle.compose.collectAsStateWithLifecycle
31+import kotlinx.coroutines.ExperimentalCoroutinesApi
32+import kotlinx.coroutines.flow.flatMapLatest
33+import kotlinx.coroutines.flow.flowOf
2734import soy.iko.opencode.R
2835import soy.iko.opencode.data.network.EventStreamClient
36+import soy.iko.opencode.di.AppContainer
2937
3038/**
3139 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
@@ -122,3 +130,32 @@ fun ConnectionBanner(
122130 }
123131 }
124132}
133+
134+/**
135+ * Convenience wrapper that collects the SSE connection state and device connectivity from
136+ * [container] and renders a [ConnectionBanner] aligned to the top center of the calling
137+ * [BoxScope]. Screens that don't already expose connection state (Files, Settings,
138+ * Diagnostics) can call this instead of duplicating the flatMapLatest boilerplate.
139+ * The retry callback triggers an SSE reconnect on the active connection.
140+ */
141+@OptIn(ExperimentalCoroutinesApi::class)
142+@Composable
143+fun BoxScope.ConnectionBannerFor(container: AppContainer) {
144+ // flatMapLatest without stateIn: collectAsStateWithLifecycle handles the lifecycle and
145+ // gives an initial value. No ViewModel scope needed, so this works on any screen.
146+ val connectionState by produceState(
147+ initialValue = EventStreamClient.ConnectionState.Disconnected,
148+ container,
149+ ) {
150+ container.activeConnection
151+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
152+ .collect { value = it }
153+ }
154+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
155+ ConnectionBanner(
156+ state = connectionState,
157+ modifier = Modifier.align(Alignment.TopCenter),
158+ isOnline = isOnline,
159+ onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
160+ )
161+}
app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.ktnew file+47
@@ -0,0 +1,47 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.provider.Settings
4+import androidx.compose.animation.ContentTransform
5+import androidx.compose.animation.EnterTransition
6+import androidx.compose.animation.ExitTransition
7+import androidx.compose.animation.core.tween
8+import androidx.compose.animation.fadeIn
9+import androidx.compose.animation.fadeOut
10+import androidx.compose.animation.slideInHorizontally
11+import androidx.compose.animation.slideOutHorizontally
12+import androidx.compose.animation.togetherWith
13+import androidx.compose.runtime.Composable
14+import androidx.compose.runtime.remember
15+import androidx.compose.ui.platform.LocalContext
16+
17+/**
18+ * Whether the user has reduced motion enabled at the system level (animator duration scale
19+ * set to 0 in Developer Options). When true, transitions should skip animations and content
20+ * swaps should be instant, respecting the user's accessibility preference.
21+ */
22+@Composable
23+fun isReducedMotion(): Boolean {
24+ val context = LocalContext.current
25+ return remember(context) {
26+ runCatching {
27+ Settings.Global.getFloat(
28+ context.contentResolver,
29+ Settings.Global.ANIMATOR_DURATION_SCALE,
30+ 1f,
31+ ) == 0f
32+ }.getOrDefault(false)
33+ }
34+}
35+
36+/**
37+ * Nav transition that respects reduced motion: a horizontal slide + fade normally, but an
38+ * instant swap when the user has disabled animations. Uses the shared [NetworkConfig] durations.
39+ */
40+@Composable
41+fun motionAwareNavTransition(): ContentTransform {
42+ if (isReducedMotion()) {
43+ return EnterTransition.None togetherWith ExitTransition.None
44+ }
45+ return slideInHorizontally(tween(220)) { it } + fadeIn(tween(180)) togetherWith
46+ slideOutHorizontally(tween(220)) { -it } + fadeOut(tween(180))
47+}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+83−3
@@ -1,24 +1,33 @@
11package soy.iko.opencode.ui.components
22
33import android.util.Base64
4+import androidx.compose.foundation.background
45import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.gestures.detectTransformGestures
57import androidx.compose.foundation.layout.Box
68import androidx.compose.foundation.layout.Column
9+import androidx.compose.foundation.layout.fillMaxSize
710import androidx.compose.foundation.layout.fillMaxWidth
811import androidx.compose.foundation.layout.heightIn
912import androidx.compose.foundation.layout.padding
13+import androidx.compose.material3.CircularProgressIndicator
14+import androidx.compose.material3.Icon
1015import androidx.compose.material3.MaterialTheme
1116import androidx.compose.material3.Text
1217import androidx.compose.runtime.Composable
1318import androidx.compose.runtime.Immutable
1419import androidx.compose.runtime.getValue
20+import androidx.compose.runtime.mutableFloatStateOf
1521import androidx.compose.runtime.mutableIntStateOf
22+import androidx.compose.runtime.mutableStateOf
1623import androidx.compose.runtime.produceState
1724import androidx.compose.runtime.remember
1825import androidx.compose.runtime.setValue
1926import androidx.compose.ui.Alignment
2027import androidx.compose.ui.Modifier
2128import androidx.compose.ui.draw.clip
29+import androidx.compose.ui.graphics.graphicsLayer
30+import androidx.compose.ui.input.pointer.pointerInput
2231import androidx.compose.ui.layout.ContentScale
2332import androidx.compose.ui.platform.LocalContext
2433import androidx.compose.ui.res.stringResource
@@ -26,14 +35,15 @@ import androidx.compose.ui.semantics.Role
2635import androidx.compose.ui.semantics.contentDescription
2736import androidx.compose.ui.semantics.semantics
2837import androidx.compose.ui.unit.dp
38+import androidx.compose.ui.window.Dialog
39+import androidx.compose.ui.window.DialogProperties
2940import coil.compose.SubcomposeAsyncImage
3041import coil.request.ImageRequest
3142import kotlinx.coroutines.Dispatchers
3243import kotlinx.coroutines.withContext
33−import androidx.compose.material3.CircularProgressIndicator
34−import androidx.compose.material3.Icon
3544import androidx.compose.material.icons.Icons
3645import androidx.compose.material.icons.filled.BrokenImage
46+import androidx.compose.material.icons.filled.Close
3747import soy.iko.opencode.R
3848import soy.iko.opencode.data.model.FilePart
3949import soy.iko.opencode.data.model.ServerProfile
@@ -175,6 +185,8 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
175185 // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
176186 // the error state instead of being a permanent broken-image icon.
177187 var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
188+ // Fullscreen zoomable viewer state: opened by tapping the inline image.
189+ var showFullscreen by remember { mutableStateOf(false) }
178190 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
179191 ImageRequest.Builder(context)
180192 .data(model)
@@ -193,7 +205,10 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
193205 contentScale = ContentScale.FillWidth,
194206 modifier = modifier
195207 .heightIn(max = 320.dp)
196− .clip(MaterialTheme.shapes.small),
208+ .clip(MaterialTheme.shapes.small)
209+ // Tap to open a fullscreen zoomable viewer so the user can inspect details
210+ // without the inline 320dp height cap.
211+ .clickable(role = Role.Button) { showFullscreen = true },
197212 loading = {
198213 Box(
199214 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
@@ -207,6 +222,71 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
207222 },
208223 error = { ImageRetry(onRetry = { retryKey++ }) },
209224 )
225+ if (showFullscreen) {
226+ FullscreenImageViewer(request = request, contentDescription = part.filename ?: stringResource(R.string.image)) {
227+ showFullscreen = false
228+ }
229+ }
230+}
231+
232+/**
233+ * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
234+ * pinch-to-zoom and pan via [detectTransformGestures], with a close button and tap-to-dismiss.
235+ * Uses a non-interactive Dialog window so it overlays the whole screen.
236+ */
237+@Composable
238+private fun FullscreenImageViewer(
239+ request: ImageRequest,
240+ contentDescription: String,
241+ onDismiss: () -> Unit,
242+) {
243+ var scale by remember { mutableFloatStateOf(1f) }
244+ var offsetX by remember { mutableFloatStateOf(0f) }
245+ var offsetY by remember { mutableFloatStateOf(0f) }
246+ Dialog(
247+ onDismissRequest = onDismiss,
248+ properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
249+ ) {
250+ Box(
251+ modifier = Modifier
252+ .fillMaxSize()
253+ .background(androidx.compose.ui.graphics.Color.Black)
254+ .pointerInput(Unit) {
255+ detectTransformGestures { _, pan, zoom, _ ->
256+ scale *= zoom
257+ offsetX += pan.x
258+ offsetY += pan.y
259+ }
260+ }
261+ .clickable(role = Role.Button, onClick = onDismiss),
262+ contentAlignment = Alignment.Center,
263+ ) {
264+ SubcomposeAsyncImage(
265+ model = request,
266+ contentDescription = contentDescription,
267+ contentScale = ContentScale.Fit,
268+ modifier = Modifier
269+ .fillMaxSize()
270+ .graphicsLayer(
271+ scaleX = scale,
272+ scaleY = scale,
273+ translationX = offsetX,
274+ translationY = offsetY,
275+ ),
276+ )
277+ // Close button in the top corner.
278+ androidx.compose.material3.IconButton(
279+ onClick = onDismiss,
280+ modifier = Modifier.align(Alignment.TopEnd).padding(16.dp),
281+ ) {
282+ Icon(
283+ Icons.Filled.Close,
284+ contentDescription = stringResource(R.string.close),
285+ tint = androidx.compose.ui.graphics.Color.White,
286+ )
287+ }
288+ }
289+ }
210290}
211291
212292/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+6
@@ -73,6 +73,7 @@ import soy.iko.opencode.data.model.SymbolResult
7373import soy.iko.opencode.data.model.symbolKindLabel
7474import soy.iko.opencode.di.AppContainer
7575import soy.iko.opencode.R
76+import soy.iko.opencode.ui.components.ConnectionBannerFor
7677import soy.iko.opencode.ui.vmFactory
7778import soy.iko.opencode.util.runCatchingCancellable
7879
@@ -108,6 +109,11 @@ fun FileBrowserScreen(
108109 snackbarHost = { SnackbarHost(snackbar) },
109110 ) { padding ->
110111 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
112+ // Surface SSE connection state so a dropped stream is visible while browsing
113+ // files, not just on the chat/session screens.
114+ Box(modifier = Modifier.fillMaxWidth()) {
115+ ConnectionBannerFor(container)
116+ }
111117 // Tappable breadcrumb trail so deep paths are navigable.
112118 Breadcrumbs(
113119 path = state.path,
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+21−6
@@ -80,6 +80,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
8080import androidx.lifecycle.viewmodel.compose.viewModel
8181import soy.iko.opencode.di.AppContainer
8282import soy.iko.opencode.R
83+import soy.iko.opencode.ui.components.ConnectionBannerFor
8384import soy.iko.opencode.ui.components.DiffView
8485import soy.iko.opencode.ui.components.LocalCodeWrap
8586import soy.iko.opencode.ui.components.copyToClipboard
@@ -93,10 +94,10 @@ import kotlinx.coroutines.delay
9394import kotlinx.coroutines.flow.collect
9495import kotlinx.coroutines.withContext
9596
96−/** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
97− * split once (not twice) into this many lines at most, so it can't hold two full line
98− * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
99−private const val MAX_RENDERED_LINES = 5000
97+import soy.iko.opencode.data.network.NetworkConfig
98+
99+/** Cap on how many lines the raw viewer renders and searches. See [NetworkConfig.maxRenderedFileLines]. */
100+private const val MAX_RENDERED_LINES = NetworkConfig.maxRenderedFileLines
100101
101102@OptIn(ExperimentalMaterial3Api::class)
102103@Composable
@@ -207,7 +208,17 @@ fun FileViewScreen(
207208 )
208209 },
209210 ) { padding ->
210− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
211+ // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
212+ // can reload the file, mirroring the file browser and session list gestures. The
213+ // top-bar reload icon remains for a targeted tap.
214+ androidx.compose.material3.pulltorefresh.PullToRefreshBox(
215+ isRefreshing = state.loading && state.content != null,
216+ onRefresh = { vm.reload() },
217+ modifier = Modifier.fillMaxSize().padding(padding),
218+ ) {
219+ Box(modifier = Modifier.fillMaxSize()) {
220+ // Surface SSE connection state so a dropped stream is visible while viewing a file.
221+ ConnectionBannerFor(container)
211222 // AnimatedContent cross-fades between loading / error / content states so the
212223 // viewer doesn't snap abruptly when a reload finishes or fails.
213224 AnimatedContent(
@@ -251,6 +262,7 @@ fun FileViewScreen(
251262 onRetry = { vm.reload() },
252263 )
253264 }
265+ }
254266 }
255267 }
256268}
@@ -586,7 +598,10 @@ private fun FindBar(
586598 else MaterialTheme.colorScheme.onSurfaceVariant
587599 Text(
588600 countText,
589− style = MaterialTheme.typography.labelSmall,
601+ // labelMedium (not labelSmall) so the counter is legible for low-vision
602+ // users at large font scales; the error color still distinguishes zero
603+ // matches at a glance.
604+ style = MaterialTheme.typography.labelMedium,
590605 color = color,
591606 )
592607 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+32−14
@@ -65,6 +65,7 @@ import androidx.compose.ui.unit.dp
6565import androidx.lifecycle.compose.collectAsStateWithLifecycle
6666import androidx.lifecycle.viewmodel.compose.viewModel
6767import soy.iko.opencode.data.network.NsdDiscovery
68+import soy.iko.opencode.data.network.NetworkConfig
6869import soy.iko.opencode.di.AppContainer
6970import soy.iko.opencode.R
7071import soy.iko.opencode.ui.vmFactory
@@ -459,20 +460,17 @@ private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolea
459460 val discovery = remember { NsdDiscovery(context) }
460461 val servers by remember(discovery) { discovery.discover() }
461462 .collectAsStateWithLifecycle(initialValue = emptyList())
462− if (servers.isEmpty()) {
463− Row(
464− verticalAlignment = Alignment.CenterVertically,
465− modifier = Modifier.padding(vertical = 8.dp),
466− ) {
467− CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
468− Text(
469− stringResource(R.string.searching_network),
470− style = MaterialTheme.typography.bodySmall,
471− color = MaterialTheme.colorScheme.onSurfaceVariant,
472− modifier = Modifier.padding(start = 8.dp),
473− )
474− }
475− } else {
463+ // mDNS discovery is a continuous flow that never emits a terminal "no results"
464+ // state — on a network with no opencode servers the spinner would spin forever.
465+ // After the timeout, swap the spinner for a "no servers found" message. Discovery
466+ // keeps running, so a late server can still replace the message when it arrives.
467+ var searchTimedOut by remember { mutableStateOf(false) }
468+ LaunchedEffect(Unit) {
469+ searchTimedOut = false
470+ kotlinx.coroutines.delay(NetworkConfig.nsdDiscoveryNoResultTimeoutMs)
471+ searchTimedOut = true
472+ }
473+ if (servers.isNotEmpty()) {
476474 servers.forEach { server ->
477475 Column(
478476 modifier = Modifier
@@ -490,6 +488,26 @@ private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolea
490488 )
491489 }
492490 }
491+ } else if (searchTimedOut) {
492+ Text(
493+ stringResource(R.string.no_servers_found),
494+ style = MaterialTheme.typography.bodySmall,
495+ color = MaterialTheme.colorScheme.onSurfaceVariant,
496+ modifier = Modifier.padding(vertical = 8.dp),
497+ )
498+ } else {
499+ Row(
500+ verticalAlignment = Alignment.CenterVertically,
501+ modifier = Modifier.padding(vertical = 8.dp),
502+ ) {
503+ CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
504+ Text(
505+ stringResource(R.string.searching_network),
506+ style = MaterialTheme.typography.bodySmall,
507+ color = MaterialTheme.colorScheme.onSurfaceVariant,
508+ modifier = Modifier.padding(start = 8.dp),
509+ )
510+ }
493511 }
494512 }
495513 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+7−2
@@ -285,8 +285,13 @@ class ServerEditViewModel(
285285 val probedRequireHttps = s.requireHttps
286286 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
287287 viewModelScope.launch {
288+ // Bound the probe so a hung/unresponsive server doesn't leave the "Testing…"
289+ // spinner spinning indefinitely. A timeout surfaces as a failure (the probe
290+ // didn't confirm credentials), not a silent hang.
288291 val result = runCatchingCancellable {
289− container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
292+ withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) {
293+ container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
294+ }
290295 }
291296 result.onSuccess { ok ->
292297 _state.update {
@@ -295,7 +300,7 @@ class ServerEditViewModel(
295300 it.copy(
296301 testingCredentials = false,
297302 credentialsResult = ok,
298− error = if (ok) null else container.string(R.string.credentials_rejected),
303+ error = if (ok == true) null else container.string(R.string.credentials_rejected),
299304 )
300305 }
301306 }.onFailure { e ->
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+2
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.session
33import androidx.compose.foundation.horizontalScroll
44import androidx.compose.foundation.layout.Column
55import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.defaultMinSize
67import androidx.compose.foundation.layout.fillMaxWidth
78import androidx.compose.foundation.layout.heightIn
89import androidx.compose.foundation.layout.padding
@@ -230,6 +231,7 @@ private fun DirectoryRow(
230231 Row(
231232 modifier = Modifier
232233 .fillMaxWidth()
234+ .defaultMinSize(minHeight = 48.dp)
233235 .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onSelect)
234236 .padding(vertical = 4.dp),
235237 verticalAlignment = Alignment.CenterVertically,
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+2
@@ -660,6 +660,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Bool
660660 }
661661 ThemeMode.LIGHT -> LightSwatches
662662 ThemeMode.DARK -> DarkSwatches
663+ ThemeMode.AMOLED -> DarkSwatches
663664 }
664665 Row(
665666 modifier = Modifier
@@ -675,6 +676,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Bool
675676 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
676677 ThemeMode.LIGHT -> stringResource(R.string.theme_light)
677678 ThemeMode.DARK -> stringResource(R.string.theme_dark)
679+ ThemeMode.AMOLED -> stringResource(R.string.theme_amoled)
678680 },
679681 style = MaterialTheme.typography.bodyLarge,
680682 modifier = Modifier.weight(1f),
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+15
@@ -95,16 +95,31 @@ private val LightColors = lightColorScheme(
9595 scrim = Color(0xFF000000),
9696)
9797
98+/**
99+ * AMOLED — the Tokyo Night dark palette with pure-black backgrounds (#000000) so OLED
100+ * displays power off pixels in large black areas, extending battery life. The accent
101+ * colours stay the same so the brand identity is preserved; only the backgrounds and
102+ * surfaces drop to true black.
103+ */
104+private val AmoledColors = DarkColors.copy(
105+ background = Color(0xFF000000),
106+ surface = Color(0xFF000000),
107+ surfaceVariant = Color(0xFF1A1F2E),
108+ inverseOnSurface = Color(0xFF000000),
109+)
110+
98111@Composable
99112fun OpencodeTheme(
100113 darkTheme: Boolean = isSystemInDarkTheme(),
101114 dynamicColor: Boolean = false,
115+ amoled: Boolean = false,
102116 content: @Composable () -> Unit,
103117) {
104118 val context = LocalContext.current
105119 val colorScheme = when {
106120 dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
107121 if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
122+ darkTheme && amoled -> AmoledColors
108123 darkTheme -> DarkColors
109124 else -> LightColors
110125 }
app/src/main/res/values-es/strings.xml+5
@@ -32,6 +32,7 @@
3232 <string name="theme_system">Sistema</string>
3333 <string name="theme_light">Claro</string>
3434 <string name="theme_dark">Oscuro</string>
35+ <string name="theme_amoled">Negro AMOLED</string>
3536
3637 <!-- Connection banner -->
3738 <string name="connecting">Conectando…</string>
@@ -75,6 +76,7 @@
7576 <string name="test_credentials">Probar credenciales</string>
7677 <string name="discover_on_network">Descubrir en la red</string>
7778 <string name="searching_network">Buscando en la red local…</string>
79+ <string name="no_servers_found">No se encontraron servidores de opencode en la red local.</string>
7880 <string name="testing_credentials">Probando…</string>
7981 <string name="credentials_ok">Credenciales aceptadas.</string>
8082 <string name="credentials_rejected">Credenciales rechazadas.</string>
@@ -108,6 +110,8 @@
108110 <string name="session">Sesión</string>
109111 <string name="default_model">modelo predeterminado</string>
110112 <string name="message_placeholder">Escribe a opencode…</string>
113+ <string name="expand_editor">Expandir editor</string>
114+ <string name="collapse_editor">Contraer</string>
111115 <string name="empty_chat_title">Inicia una conversación</string>
112116 <string name="empty_chat_hint">Escribe un mensaje abajo para empezar.</string>
113117 <string name="empty_chat_suggest_1">Explica qué hace este código</string>
@@ -163,6 +167,7 @@
163167 <string name="permission_title">Permiso solicitado</string>
164168 <string name="permission_text">¿Permitir que esta herramienta se ejecute?</string>
165169 <string name="allow_once">Permitir una vez</string>
170+ <string name="allow_for_session">Permitir por esta sesión</string>
166171 <string name="always_allow">Permitir siempre</string>
167172 <string name="reject">Rechazar</string>
168173
app/src/main/res/values/strings.xml+5
@@ -32,6 +32,7 @@
3232 <string name="theme_system">System</string>
3333 <string name="theme_light">Light</string>
3434 <string name="theme_dark">Dark</string>
35+ <string name="theme_amoled">AMOLED Black</string>
3536
3637 <!-- Connection banner -->
3738 <string name="connecting">Connecting…</string>
@@ -75,6 +76,7 @@
7576 <string name="test_credentials">Test credentials</string>
7677 <string name="discover_on_network">Discover on network</string>
7778 <string name="searching_network">Searching the local network…</string>
79+ <string name="no_servers_found">No opencode servers found on the local network.</string>
7880 <string name="testing_credentials">Testing…</string>
7981 <string name="credentials_ok">Credentials accepted.</string>
8082 <string name="credentials_rejected">Credentials rejected.</string>
@@ -108,6 +110,8 @@
108110 <string name="session">Session</string>
109111 <string name="default_model">default model</string>
110112 <string name="message_placeholder">Message opencode…</string>
113+ <string name="expand_editor">Expand editor</string>
114+ <string name="collapse_editor">Collapse</string>
111115 <string name="empty_chat_title">Start a conversation</string>
112116 <string name="empty_chat_hint">Type a message below to begin.</string>
113117 <string name="empty_chat_suggest_1">Explain what this codebase does</string>
@@ -163,6 +167,7 @@
163167 <string name="permission_title">Permission requested</string>
164168 <string name="permission_text">Allow this tool to run?</string>
165169 <string name="allow_once">Allow once</string>
170+ <string name="allow_for_session">Allow for this session</string>
166171 <string name="always_allow">Always allow</string>
167172 <string name="reject">Reject</string>
168173
app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+3−1
@@ -35,7 +35,9 @@ class ConversationExportTest {
3535 @Test
3636 fun omitsTitleWhenBlank() {
3737 val md = buildConversationMarkdown(listOf(user("u1", "hi")), title = null)
38− assertTrue(md.startsWith("## You"))
38+ // The metadata header is now prepended (message count + timestamp range), so the
39+ // first message heading follows it.
40+ assertTrue(md.contains("## You"))
3941 }
4042
4143 @Test