git.iko.soy/opencode-android
on master help sign in
Fix crash dialog double-confirm and PureCoreFfi text extraction, add copy feedback, locale-aware timestamps, shorter scroll FAB, SSE banner dismiss, stop-generating control, session chevrons, Material3 splash theme, thinking label, date separators, double-back-to-exit, and crash plural fix
commit8d923dde37327b71823a86879423f982a827414e authoriko <mail@iko.soy> dateWed 8 Jul 2026, 11:55 (3 months ago) parent9fac50c
20 files changed+334−78
app/src/main/java/soy/iko/opencode/MainActivity.kt+25−3 app/src/main/java/soy/iko/opencode/core/Core.kt+6 app/src/main/java/soy/iko/opencode/core/PureCoreFfi.kt+28−8 app/src/main/java/soy/iko/opencode/ui/components/ChatInputBar.kt+25−8 app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.kt+24−31 app/src/main/java/soy/iko/opencode/ui/components/DateSeparator.kt+72 app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.kt+2 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+26−11 app/src/main/java/soy/iko/opencode/ui/components/SseDisconnectedBanner.kt+10−3 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+45−6 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+1 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+8 app/src/main/res/values-night-v31/themes.xml+3−2 app/src/main/res/values-night/colors.xml+4 app/src/main/res/values-night/themes.xml+3−1 app/src/main/res/values-v31/themes.xml+3−2 app/src/main/res/values/colors.xml+4 app/src/main/res/values/strings.xml+8−1 app/src/main/res/values/themes.xml+8−1 app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.kt+29−1
app/src/main/java/soy/iko/opencode/MainActivity.kt+25−3
@@ -1,6 +1,7 @@
11package soy.iko.opencode
22
33import android.os.Bundle
4+import android.widget.Toast
45import androidx.activity.ComponentActivity
56import androidx.activity.compose.setContent
67import androidx.activity.enableEdgeToEdge
@@ -14,6 +15,10 @@ import androidx.compose.animation.slideOutHorizontally
1415import androidx.compose.runtime.Composable
1516import androidx.compose.runtime.collectAsState
1617import androidx.compose.runtime.getValue
18+import androidx.compose.runtime.mutableLongStateOf
19+import androidx.compose.runtime.remember
20+import androidx.compose.runtime.setValue
21+import androidx.compose.ui.platform.LocalContext
1722import androidx.lifecycle.viewmodel.compose.viewModel
1823import soy.iko.opencode.core.Core
1924import soy.iko.opencode.core.Event
@@ -38,14 +43,29 @@ class MainActivity : ComponentActivity() {
3843@Composable
3944fun App(core: Core = viewModel()) {
4045 val view by core.view.collectAsState()
46+ val context = LocalContext.current
47+ var lastBackPress by remember { mutableLongStateOf(0L) }
4148
4249 // Map the system back button to logical in-app navigation so it never
43− // silently exits the app from a sub-screen.
44− BackHandler(enabled = view.screen != Screen.CONNECT) {
50+ // silently exits the app from a sub-screen. On the root Connect screen,
51+ // require a double press to exit (with a toast hint the first time).
52+ BackHandler(enabled = true) {
4553 when (view.screen) {
4654 Screen.CHAT -> core.update(Event.NavigateToSessions)
4755 Screen.SESSIONS -> core.update(Event.NavigateToConnect)
48− Screen.CONNECT -> { /* no-op; CONNECT is the root */ }
56+ Screen.CONNECT -> {
57+ val now = System.currentTimeMillis()
58+ if (now - lastBackPress < EXIT_CONFIRMATION_MS) {
59+ (context as? android.app.Activity)?.finish()
60+ } else {
61+ lastBackPress = now
62+ Toast.makeText(
63+ context,
64+ R.string.press_back_again_to_exit,
65+ Toast.LENGTH_SHORT,
66+ ).show()
67+ }
68+ }
4969 }
5070 }
5171
@@ -76,3 +96,5 @@ fun App(core: Core = viewModel()) {
7696 }
7797 }
7898}
99+
100+private const val EXIT_CONFIRMATION_MS = 2000L
app/src/main/java/soy/iko/opencode/core/Core.kt+6
@@ -97,6 +97,11 @@ class Core(application: Application) : AndroidViewModel(application) {
9797 sseClient.reconnect()
9898 }
9999
100+ /** Surface a transient "Copied" toast/snackbar to the user. */
101+ fun notifyCopied() {
102+ viewModelScope.launch { _info.emit(SUCCESS_COPIED) }
103+ }
104+
100105 private fun processEffect(request: Request) {
101106 when (val effect = request.effect) {
102107 is Effect.Render -> {
@@ -218,5 +223,6 @@ class Core(application: Application) : AndroidViewModel(application) {
218223
219224 const val SUCCESS_CONNECTED = "__success_connected__"
220225 const val SUCCESS_SESSION_CREATED = "__success_session_created__"
226+ const val SUCCESS_COPIED = "__success_copied__"
221227 }
222228}
app/src/main/java/soy/iko/opencode/core/PureCoreFfi.kt+28−8
@@ -383,7 +383,8 @@ internal fun parseMessages(json: String): List<PureMessage> {
383383 val role = extractJsonString(obj, "role") ?: ""
384384 val time = extractJsonNumber(obj, "created") ?: 0uL
385385 if (id.isNotEmpty() && role.isNotEmpty()) {
386− messages.add(PureMessage(id, role, "", time))
386+ val text = extractMessageText(obj)
387+ messages.add(PureMessage(id, role, text, time))
387388 }
388389 }
389390 }
@@ -392,24 +393,43 @@ internal fun parseMessages(json: String): List<PureMessage> {
392393 return messages
393394}
394395
396+/// Extract the text from a message part. opencode messages nest the user/assistant
397+/// text inside a `parts` array; we do a shallow scan for `"text":"..."` after the
398+/// first `"parts"` key, falling back to a top-level `"text"` field.
399+internal fun extractMessageText(obj: String): String {
400+ val partsIdx = obj.indexOf("\"parts\"")
401+ if (partsIdx >= 0) {
402+ val rest = obj.substring(partsIdx)
403+ val text = extractJsonString(rest, "text")
404+ if (text != null) return text
405+ }
406+ return extractJsonString(obj, "text") ?: ""
407+}
408+
395409internal fun extractJsonString(json: String, key: String): String? {
396410 val pattern = "\"$key\""
397− val idx = json.indexOf(pattern) ?: return null
411+ val idx = json.indexOf(pattern)
412+ if (idx < 0) return null
398413 val rest = json.substring(idx + pattern.length)
399− val colon = rest.indexOf(':') ?: return null
414+ val colon = rest.indexOf(':')
415+ if (colon < 0) return null
400416 val rest2 = rest.substring(colon + 1)
401− val quote = rest2.indexOf('"') ?: return null
417+ val quote = rest2.indexOf('"')
418+ if (quote < 0) return null
402419 val rest3 = rest2.substring(quote + 1)
403− val end = rest3.indexOf('"') ?: return null
420+ val end = rest3.indexOf('"')
421+ if (end < 0) return null
404422 return rest3.substring(0, end)
405423}
406424
407425internal fun extractJsonNumber(json: String, key: String): ULong? {
408426 val pattern = "\"$key\""
409− val idx = json.indexOf(pattern) ?: return null
427+ val idx = json.indexOf(pattern)
428+ if (idx < 0) return null
410429 val rest = json.substring(idx + pattern.length)
411− val colon = rest.indexOf(':') ?: return null
430+ val colon = rest.indexOf(':')
431+ if (colon < 0) return null
412432 val rest2 = rest.substring(colon + 1).trimStart()
413− val end = rest2.indexOfFirst { !it.isDigit() }.let { if (it == -1) rest2.length else it }
433+ val end = rest2.indexOfFirst { !it.isDigit() }.let { if (it < 0) rest2.length else it }
414434 return rest2.substring(0, end).toULongOrNull()
415435}
app/src/main/java/soy/iko/opencode/ui/components/ChatInputBar.kt+25−8
@@ -12,6 +12,7 @@ import androidx.compose.foundation.text.KeyboardActions
1212import androidx.compose.foundation.text.KeyboardOptions
1313import androidx.compose.material.icons.Icons
1414import androidx.compose.material.icons.automirrored.filled.Send
15+import androidx.compose.material.icons.filled.Stop
1516import androidx.compose.material3.Icon
1617import androidx.compose.material3.IconButton
1718import androidx.compose.material3.MaterialTheme
@@ -38,6 +39,8 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
3839 * tapping the icon.
3940 * - Send is disabled while [enabled] is false (e.g. during loading) or when
4041 * the text is blank.
42+ * - When [generating] is true, a Stop button replaces the Send button so the
43+ * user can cancel the wait; tapping it calls [onStop].
4144 */
4245@Composable
4346fun ChatInputBar(
@@ -46,6 +49,9 @@ fun ChatInputBar(
4649 onSend: () -> Unit,
4750 enabled: Boolean,
4851 modifier: Modifier = Modifier,
52+ generating: Boolean = false,
53+ onStop: () -> Unit = {},
54+ stopContentDescription: String = "Stop generating",
4955 placeholder: String = "Message…",
5056 sendContentDescription: String = "Send",
5157) {
@@ -75,14 +81,25 @@ fun ChatInputBar(
7581 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
7682 keyboardActions = KeyboardActions(onSend = { onSend() }),
7783 )
78− IconButton(
79− onClick = onSend,
80− enabled = enabled && text.isNotBlank(),
81− modifier = Modifier.semantics {
82− contentDescription = sendContentDescription
83− },
84− ) {
85− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
84+ if (generating) {
85+ IconButton(
86+ onClick = onStop,
87+ modifier = Modifier.semantics {
88+ contentDescription = stopContentDescription
89+ },
90+ ) {
91+ Icon(Icons.Default.Stop, contentDescription = null)
92+ }
93+ } else {
94+ IconButton(
95+ onClick = onSend,
96+ enabled = enabled && text.isNotBlank(),
97+ modifier = Modifier.semantics {
98+ contentDescription = sendContentDescription
99+ },
100+ ) {
101+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
102+ }
86103 }
87104 }
88105 }
app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.kt+24−31
@@ -34,9 +34,12 @@ import java.io.File
3434 * recomposition) rather than on every recomposition — [File.readText] is disk
3535 * I/O and shouldn't run in composition repeatedly.
3636 *
37− * "Clear all" is a destructive action: it's placed in the dismiss slot (left,
38− * neutral) and tinted with the error color, and a confirmation step guards
39− * against accidental taps. "Close" is the affirmative (right) action.
37+ * "Clear all" is a destructive action guarded by a confirmation step. The
38+ * single dialog swaps its content and buttons between the list view and the
39+ * confirm view, so there's never a second dialog stacked on top.
40+ *
41+ * - List view: dismiss = "Clear all" (error-tinted), confirm = "Close"
42+ * - Confirm view: dismiss = "Cancel", confirm = "Clear" (error-tinted)
4043 */
4144@Composable
4245fun CrashLogDialog(
@@ -47,6 +50,7 @@ fun CrashLogDialog(
4750 title: String = "Crash Reports",
4851 clearLabel: String = "Clear all",
4952 closeLabel: String = "Close",
53+ cancelLabel: String = "Cancel",
5054 clearConfirmLabel: String = "Clear all crash reports?",
5155 clearConfirmYesLabel: String = "Clear",
5256) {
@@ -94,14 +98,26 @@ fun CrashLogDialog(
9498 }
9599 },
96100 confirmButton = {
97− // Primary (right) button: "Close" is the non-destructive action.
98− TextButton(onClick = onDismiss) { Text(closeLabel) }
101+ if (confirmingClear) {
102+ // Affirmative: clear (error-tinted).
103+ TextButton(onClick = {
104+ onClear()
105+ confirmingClear = false
106+ }) {
107+ Text(
108+ text = clearConfirmYesLabel,
109+ color = MaterialTheme.colorScheme.error,
110+ )
111+ }
112+ } else {
113+ // Non-destructive: close.
114+ TextButton(onClick = onDismiss) { Text(closeLabel) }
115+ }
99116 },
100117 dismissButton = {
101118 if (confirmingClear) {
102− // Confirm step: the affirmative "Clear" sits on the right now,
103− // so expose a "Cancel" on the left to abort.
104− TextButton(onClick = { confirmingClear = false }) { Text(closeLabel) }
119+ // Abort the clear.
120+ TextButton(onClick = { confirmingClear = false }) { Text(cancelLabel) }
105121 } else {
106122 // Destructive action in the dismiss slot (left), tinted error.
107123 TextButton(onClick = { confirmingClear = true }) {
@@ -113,29 +129,6 @@ fun CrashLogDialog(
113129 }
114130 },
115131 )
116−
117− // Second-stage confirmation dialog: actually clear when the user confirms.
118− if (confirmingClear) {
119− AlertDialog(
120− onDismissRequest = { confirmingClear = false },
121− title = { Text(title) },
122− text = { Text(clearConfirmLabel) },
123− confirmButton = {
124− TextButton(onClick = {
125− onClear()
126− confirmingClear = false
127− }) {
128− Text(
129− text = clearConfirmYesLabel,
130− color = MaterialTheme.colorScheme.error,
131− )
132− }
133− },
134− dismissButton = {
135− TextButton(onClick = { confirmingClear = false }) { Text(closeLabel) }
136− },
137− )
138− }
139132}
140133
141134@Preview(showBackground = true)
app/src/main/java/soy/iko/opencode/ui/components/DateSeparator.ktnew file+72
@@ -0,0 +1,72 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.text.format.DateFormat
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.padding
7+import androidx.compose.material3.MaterialTheme
8+import androidx.compose.material3.Text
9+import androidx.compose.runtime.Composable
10+import androidx.compose.runtime.remember
11+import androidx.compose.ui.Alignment
12+import androidx.compose.ui.Modifier
13+import androidx.compose.ui.platform.LocalContext
14+import androidx.compose.ui.semantics.contentDescription
15+import androidx.compose.ui.semantics.semantics
16+import androidx.compose.ui.text.style.TextAlign
17+import androidx.compose.ui.tooling.preview.Preview
18+import androidx.compose.ui.unit.dp
19+import soy.iko.opencode.ui.theme.OpencodeTheme
20+import java.util.Calendar
21+import java.util.Date
22+import java.util.Locale
23+
24+/**
25+ * A centered date label inserted between messages that fall on different
26+ * calendar days. Uses the user's locale date format (via [DateFormat.getLongDateFormat])
27+ * so it respects system preferences.
28+ */
29+@Composable
30+fun DateSeparator(epochSeconds: ULong, modifier: Modifier = Modifier) {
31+ val context = LocalContext.current
32+ val label = remember(epochSeconds) { formatDateLabel(epochSeconds, context) }
33+ Box(
34+ modifier = modifier
35+ .fillMaxWidth()
36+ .padding(vertical = 8.dp)
37+ .semantics { contentDescription = label },
38+ contentAlignment = Alignment.Center,
39+ ) {
40+ Text(
41+ text = label,
42+ style = MaterialTheme.typography.labelSmall,
43+ color = MaterialTheme.colorScheme.onSurfaceVariant,
44+ textAlign = TextAlign.Center,
45+ )
46+ }
47+}
48+
49+private fun formatDateLabel(epochSeconds: ULong, context: android.content.Context): String {
50+ val date = Date(epochSeconds.toLong() * 1000)
51+ val formatter = DateFormat.getLongDateFormat(context)
52+ return formatter.format(date)
53+}
54+
55+/**
56+ * Returns the calendar day (as epoch days) for the given epoch seconds,
57+ * in the device's default timezone. Returns 0 for time == 0 (unknown).
58+ */
59+internal fun dayOfEpoch(epochSeconds: ULong): Int {
60+ if (epochSeconds == 0uL) return 0
61+ val cal = Calendar.getInstance(Locale.getDefault())
62+ cal.timeInMillis = epochSeconds.toLong() * 1000
63+ return cal.get(Calendar.YEAR) * 366 + cal.get(Calendar.DAY_OF_YEAR)
64+}
65+
66+@Preview(showBackground = true)
67+@Composable
68+private fun DateSeparatorPreview() {
69+ OpencodeTheme {
70+ DateSeparator(epochSeconds = 1_700_000_000uL)
71+ }
72+}
app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.kt+2
@@ -72,6 +72,7 @@ fun InfoHost(
7272 core: Core,
7373 successConnectedLabel: String,
7474 successSessionCreatedLabel: String,
75+ copiedLabel: String = "Copied",
7576): SnackbarHostState {
7677 val snackbarHostState = remember { SnackbarHostState() }
7778 LaunchedEffect(snackbarHostState) {
@@ -79,6 +80,7 @@ fun InfoHost(
7980 val message = when (key) {
8081 Core.SUCCESS_CONNECTED -> successConnectedLabel
8182 Core.SUCCESS_SESSION_CREATED -> successSessionCreatedLabel
83+ Core.SUCCESS_COPIED -> copiedLabel
8284 else -> key
8385 }
8486 snackbarHostState.showSnackbar(
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+26−11
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.components
22
3+import android.text.format.DateFormat
34import androidx.compose.animation.core.RepeatMode
45import androidx.compose.animation.core.animateFloat
56import androidx.compose.animation.core.infiniteRepeatable
@@ -25,6 +26,7 @@ import androidx.compose.runtime.remember
2526import androidx.compose.ui.Modifier
2627import androidx.compose.ui.hapticfeedback.HapticFeedbackType
2728import androidx.compose.ui.platform.LocalClipboardManager
29+import androidx.compose.ui.platform.LocalContext
2830import androidx.compose.ui.platform.LocalDensity
2931import androidx.compose.ui.platform.LocalHapticFeedback
3032import androidx.compose.ui.platform.LocalWindowInfo
@@ -44,9 +46,7 @@ import soy.iko.opencode.ui.theme.AssistantBubbleShape
4446import soy.iko.opencode.ui.theme.Dimens
4547import soy.iko.opencode.ui.theme.OpencodeTheme
4648import soy.iko.opencode.ui.theme.UserBubbleShape
47−import java.text.SimpleDateFormat
4849import java.util.Date
49−import java.util.Locale
5050
5151/**
5252 * A single chat message rendered as a left/right-aligned bubble. Assistant
@@ -66,6 +66,7 @@ import java.util.Locale
6666fun MessageBubble(
6767 message: MessageView,
6868 modifier: Modifier = Modifier,
69+ onCopied: () -> Unit = {},
6970) {
7071 val isUser = message.role == "user"
7172 val maxWidth = maxBubbleWidthDp()
@@ -74,9 +75,11 @@ fun MessageBubble(
7475 } else {
7576 stringResource(R.string.chat_role_assistant)
7677 }
77− val timeLabel = remember(message.time) { formatTimestamp(message.time) }
78+ val context = LocalContext.current
79+ val timeLabel = remember(message.time) { formatTimestamp(message.time, context) }
7880 val clipboardManager = LocalClipboardManager.current
7981 val hapticFeedback = LocalHapticFeedback.current
82+ val onCopiedCallback = onCopied
8083 // Build a single TalkBack utterance that includes the role and the
8184 // message body. Setting an explicit contentDescription on a merge node
8285 // replaces descendant text, so we must include the body ourselves rather
@@ -98,6 +101,7 @@ fun MessageBubble(
98101 if (message.text.isNotBlank()) {
99102 clipboardManager.setText(AnnotatedString(message.text))
100103 hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
104+ onCopiedCallback()
101105 }
102106 },
103107 onClick = {},
@@ -197,7 +201,8 @@ fun MessageBubble(
197201@Composable
198202fun GeneratingIndicator(modifier: Modifier = Modifier) {
199203 val maxWidth = maxBubbleWidthDp()
200− val generatingLabel = stringResource(R.string.chat_cd_generating)
204+ val generatingLabel = stringResource(R.string.chat_generating_label)
205+ val cdLabel = stringResource(R.string.chat_cd_generating)
201206 val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
202207 // Three dots that pulse in sequence: each dot scales/fades with a
203208 // staggered delay so the indicator reads as "typing" rather than a
@@ -244,7 +249,7 @@ fun GeneratingIndicator(modifier: Modifier = Modifier) {
244249 ) {
245250 Row(
246251 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
247− horizontalArrangement = Arrangement.spacedBy(Dimens.spaceTiny),
252+ horizontalArrangement = Arrangement.spacedBy(Dimens.spaceSmall),
248253 modifier = Modifier
249254 .padding(
250255 horizontal = Dimens.bubbleHorizontalPadding,
@@ -253,12 +258,22 @@ fun GeneratingIndicator(modifier: Modifier = Modifier) {
253258 .semantics(mergeDescendants = true) {
254259 // Announced when the indicator appears/disappears.
255260 liveRegion = LiveRegionMode.Polite
256− contentDescription = generatingLabel
261+ contentDescription = cdLabel
257262 },
258263 ) {
259− GeneratingDot(alpha = phase1, color = dotColor)
260− GeneratingDot(alpha = phase2, color = dotColor)
261− GeneratingDot(alpha = phase3, color = dotColor)
264+ Text(
265+ text = generatingLabel,
266+ style = MaterialTheme.typography.bodyMedium,
267+ color = MaterialTheme.colorScheme.onSurfaceVariant,
268+ )
269+ Row(
270+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
271+ horizontalArrangement = Arrangement.spacedBy(Dimens.spaceTiny),
272+ ) {
273+ GeneratingDot(alpha = phase1, color = dotColor)
274+ GeneratingDot(alpha = phase2, color = dotColor)
275+ GeneratingDot(alpha = phase3, color = dotColor)
276+ }
262277 }
263278 }
264279 }
@@ -281,9 +296,9 @@ private fun GeneratingDot(alpha: Float, color: androidx.compose.ui.graphics.Colo
281296 }
282297}
283298
284−private fun formatTimestamp(epochSeconds: ULong): String {
299+private fun formatTimestamp(epochSeconds: ULong, context: android.content.Context): String {
285300 val date = Date(epochSeconds.toLong() * 1000)
286− val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
301+ val formatter = DateFormat.getTimeFormat(context)
287302 return formatter.format(date)
288303}
289304
app/src/main/java/soy/iko/opencode/ui/components/SseDisconnectedBanner.kt+10−3
@@ -26,8 +26,9 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
2626
2727/**
2828 * A thin banner shown above the message list when the SSE live stream drops.
29− * Offers a "Reconnect" action. The banner is a polite live region so screen
30− * readers announce the disconnection once.
29+ * Offers a "Reconnect" action and a "Dismiss" action so the user can keep
30+ * reading history without the banner persisting. The banner is a polite live
31+ * region so screen readers announce the disconnection once.
3132 *
3233 * Only render this when [SseState] is [SseState.Error] or an unexpected
3334 * [SseState.Disconnected] while a chat is active.
@@ -36,8 +37,10 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
3637fun SseDisconnectedBanner(
3738 message: String,
3839 onReconnect: () -> Unit,
40+ onDismiss: () -> Unit,
3941 modifier: Modifier = Modifier,
4042 reconnectLabel: String = "Reconnect",
43+ dismissLabel: String = "Dismiss",
4144) {
4245 Surface(
4346 color = MaterialTheme.colorScheme.errorContainer,
@@ -71,7 +74,10 @@ fun SseDisconnectedBanner(
7174 maxLines = 2,
7275 )
7376 }
74− TextButton(onClick = onReconnect) { Text(reconnectLabel) }
77+ Row(verticalAlignment = Alignment.CenterVertically) {
78+ TextButton(onClick = onDismiss) { Text(dismissLabel) }
79+ TextButton(onClick = onReconnect) { Text(reconnectLabel) }
80+ }
7581 }
7682 }
7783}
@@ -83,6 +89,7 @@ private fun SseDisconnectedBannerPreview() {
8389 SseDisconnectedBanner(
8490 message = "Live stream disconnected",
8591 onReconnect = {},
92+ onDismiss = {},
8693 )
8794 }
8895}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+45−6
@@ -50,6 +50,7 @@ import soy.iko.opencode.core.Event
5050import soy.iko.opencode.core.MessageView
5151import soy.iko.opencode.core.SseState
5252import soy.iko.opencode.ui.components.ChatInputBar
53+import soy.iko.opencode.ui.components.DateSeparator
5354import soy.iko.opencode.ui.components.DualSnackbarHost
5455import soy.iko.opencode.ui.components.EmptyState
5556import soy.iko.opencode.ui.components.ErrorHost
@@ -58,6 +59,7 @@ import soy.iko.opencode.ui.components.InfoHost
5859import soy.iko.opencode.ui.components.LoadingPlaceholder
5960import soy.iko.opencode.ui.components.MessageBubble
6061import soy.iko.opencode.ui.components.SseDisconnectedBanner
62+import soy.iko.opencode.ui.components.dayOfEpoch
6163import soy.iko.opencode.ui.theme.Dimens
6264import soy.iko.opencode.ui.theme.OpencodeTheme
6365
@@ -70,6 +72,7 @@ fun ChatScreen(core: Core) {
7072 val listState = rememberLazyListState()
7173 var scrollRequest by remember { mutableIntStateOf(0) }
7274 var menuExpanded by remember { mutableStateOf(false) }
75+ var sseBannerDismissed by remember { mutableStateOf(false) }
7376 val inputFocusRequester = remember { FocusRequester() }
7477
7578 // Track whether the user is parked at the bottom of the list. We only
@@ -118,10 +121,18 @@ fun ChatScreen(core: Core) {
118121 core = core,
119122 successConnectedLabel = stringResource(R.string.connect_success),
120123 successSessionCreatedLabel = stringResource(R.string.session_created),
124+ copiedLabel = stringResource(R.string.chat_copied),
121125 )
122126
123− val showSseBanner = sseState is SseState.Error ||
124− (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
127+ val showSseBanner = (sseState is SseState.Error ||
128+ (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)) &&
129+ !sseBannerDismissed
130+
131+ // Reset the dismissed flag whenever a fresh SSE error arrives so the
132+ // banner reappears for a new disconnect.
133+ LaunchedEffect((sseState as? SseState.Error)?.message) {
134+ if (sseState is SseState.Error) sseBannerDismissed = false
135+ }
125136
126137 // Resolve nullable session fields into locals once per recomposition so we
127138 // never smart-cast a delegate-backed collected property across calls.
@@ -219,6 +230,9 @@ fun ChatScreen(core: Core) {
219230 // Disable while loading OR while the assistant is generating,
220231 // so users can't fire a second message mid-reply.
221232 enabled = !view.loading && !view.generating,
233+ generating = view.generating,
234+ onStop = { sessionId?.let { core.update(Event.LoadMessages(it)) } },
235+ stopContentDescription = stringResource(R.string.chat_cd_stop),
222236 placeholder = stringResource(R.string.chat_input_placeholder),
223237 sendContentDescription = stringResource(R.string.chat_cd_send),
224238 modifier = Modifier.focusRequester(inputFocusRequester),
@@ -237,8 +251,13 @@ fun ChatScreen(core: Core) {
237251 ?: stringResource(R.string.sse_disconnected)
238252 SseDisconnectedBanner(
239253 message = bannerMessage,
240− onReconnect = { core.reconnectSse() },
254+ onReconnect = {
255+ sseBannerDismissed = false
256+ core.reconnectSse()
257+ },
258+ onDismiss = { sseBannerDismissed = true },
241259 reconnectLabel = stringResource(R.string.sse_reconnect),
260+ dismissLabel = stringResource(R.string.sse_dismiss),
242261 )
243262 }
244263
@@ -266,8 +285,28 @@ fun ChatScreen(core: Core) {
266285 verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
267286 contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
268287 ) {
269− items(view.messages, key = { it.id }) { message ->
270− MessageBubble(message)
288+ val msgs = view.messages
289+ for (i in msgs.indices) {
290+ val msg = msgs[i]
291+ // Insert a date separator before a message
292+ // when its calendar day differs from the
293+ // previous message's day (and it has a
294+ // valid timestamp).
295+ if (msg.time > 0uL) {
296+ val prevDay = if (i > 0) dayOfEpoch(msgs[i - 1].time) else Int.MIN_VALUE
297+ val curDay = dayOfEpoch(msg.time)
298+ if (curDay != prevDay) {
299+ item(key = "date_${msg.id}") {
300+ DateSeparator(epochSeconds = msg.time)
301+ }
302+ }
303+ }
304+ item(key = msg.id) {
305+ MessageBubble(
306+ message = msg,
307+ onCopied = { core.notifyCopied() },
308+ )
309+ }
271310 }
272311 // "Thinking" indicator shown after the user sends, while
273312 // the assistant reply hasn't arrived yet. The indicator
@@ -303,7 +342,7 @@ fun ChatScreen(core: Core) {
303342 contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
304343 )
305344 },
306− text = { Text(stringResource(R.string.chat_cd_scroll_to_bottom)) },
345+ text = { Text(stringResource(R.string.chat_scroll_to_latest)) },
307346 containerColor = MaterialTheme.colorScheme.primaryContainer,
308347 contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
309348 modifier = Modifier
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+1
@@ -283,6 +283,7 @@ fun ConnectScreen(core: Core) {
283283 title = stringResource(R.string.crash_reports_title),
284284 clearLabel = stringResource(R.string.crash_reports_clear),
285285 closeLabel = stringResource(R.string.crash_reports_close),
286+ cancelLabel = stringResource(R.string.crash_reports_cancel),
286287 clearConfirmLabel = stringResource(R.string.crash_reports_clear_confirm),
287288 clearConfirmYesLabel = stringResource(R.string.crash_reports_clear_confirm_yes),
288289 )
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+8
@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.LazyColumn
88import androidx.compose.foundation.lazy.items
99import androidx.compose.material.icons.Icons
1010import androidx.compose.material.icons.automirrored.filled.ArrowBack
11+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
1112import androidx.compose.material.icons.filled.Add
1213import androidx.compose.material.icons.filled.ChatBubble
1314import androidx.compose.material3.ExperimentalMaterial3Api
@@ -136,6 +137,13 @@ private fun SessionRow(
136137 tint = MaterialTheme.colorScheme.primary,
137138 )
138139 },
140+ trailingContent = {
141+ Icon(
142+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
143+ contentDescription = null,
144+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
145+ )
146+ },
139147 modifier = Modifier
140148 .clickable(onClick = onClick)
141149 .semantics(mergeDescendants = true) {
app/src/main/res/values-night-v31/themes.xml+3−2
@@ -1,7 +1,8 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
3− <style name="Theme.Opencode" parent="android:Theme.Material.NoActionBar">
4− <item name="android:windowSplashScreenBackground">@android:color/black</item>
3+ <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.NoActionBar">
4+ <item name="android:windowBackground">@color/splash_background</item>
5+ <item name="android:windowSplashScreenBackground">@color/splash_background</item>
56 <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
67 </style>
78</resources>
app/src/main/res/values-night/colors.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <color name="splash_background">#FF1E1E1E</color>
4+</resources>
app/src/main/res/values-night/themes.xml+3−1
@@ -1,4 +1,6 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
3− <style name="Theme.Opencode" parent="android:Theme.Material.NoActionBar" />
3+ <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.NoActionBar">
4+ <item name="android:windowBackground">@color/splash_background</item>
5+ </style>
46</resources>
app/src/main/res/values-v31/themes.xml+3−2
@@ -1,11 +1,12 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
3− <style name="Theme.Opencode" parent="android:Theme.Material.Light.NoActionBar">
3+ <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.Light.NoActionBar">
4+ <item name="android:windowBackground">@color/splash_background</item>
45 <!-- Android 12+ system splash screen: use the window background color
56 so the post-splash crossfade into the Compose UI is seamless. The
67 Compose UI paints its own background, so we only set the poster
78 color and a system icon here. -->
8− <item name="android:windowSplashScreenBackground">@android:color/white</item>
9+ <item name="android:windowSplashScreenBackground">@color/splash_background</item>
910 <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
1011 </style>
1112</resources>
app/src/main/res/values/colors.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <color name="splash_background">#FFF5F5F5</color>
4+</resources>
app/src/main/res/values/strings.xml+8−1
@@ -26,10 +26,11 @@
2626 <string name="crash_reports_title">Crash Reports</string>
2727 <plurals name="crash_reports_view">
2828 <item quantity="one">%d crash report — view</item>
29− <item quantity="other">%d crash report(s) — view</item>
29+ <item quantity="other">%d crash reports — view</item>
3030 </plurals>
3131 <string name="crash_reports_clear">Clear all</string>
3232 <string name="crash_reports_close">Close</string>
33+ <string name="crash_reports_cancel">Cancel</string>
3334 <string name="crash_reports_clear_confirm">Clear all crash reports?</string>
3435 <string name="crash_reports_clear_confirm_yes">Clear</string>
3536
@@ -50,7 +51,11 @@
5051 <string name="chat_role_user">User</string>
5152 <string name="chat_role_assistant">Assistant</string>
5253 <string name="chat_cd_generating">Generating reply</string>
54+ <string name="chat_generating_label">Thinking…</string>
5355 <string name="chat_cd_scroll_to_bottom">Scroll to latest message</string>
56+ <string name="chat_scroll_to_latest">Latest</string>
57+ <string name="chat_copied">Copied</string>
58+ <string name="chat_cd_stop">Stop generating</string>
5459 <string name="chat_session_id_short">Session %s</string>
5560 <string name="chat_menu_overflow">More options</string>
5661 <string name="chat_menu_refresh">Refresh messages</string>
@@ -62,4 +67,6 @@
6267 <!-- SSE / connectivity -->
6368 <string name="sse_disconnected">Live stream disconnected</string>
6469 <string name="sse_reconnect">Reconnect</string>
70+ <string name="sse_dismiss">Dismiss</string>
71+ <string name="press_back_again_to_exit">Press back again to exit</string>
6572</resources>
app/src/main/res/values/themes.xml+8−1
@@ -1,4 +1,11 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
3− <style name="Theme.Opencode" parent="android:Theme.Material.Light.NoActionBar" />
3+ <!-- Base theme for the Compose app. Uses a neutral NoActionBar parent so
4+ the legacy Material (Material1) widget styling doesn't clash with the
5+ Compose Material3 UI that paints on top. The window background matches
6+ the Compose surface color so the pre-Compose splash crossfade is
7+ seamless. -->
8+ <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.Light.NoActionBar">
9+ <item name="android:windowBackground">@color/splash_background</item>
10+ </style>
411</resources>
app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.kt+29−1
@@ -6,7 +6,6 @@ import org.junit.Assert.assertNotNull
66import org.junit.Assert.assertNull
77import org.junit.Assert.assertTrue
88import org.junit.Test
9−
109class PureCoreFfiTest {
1110
1211 private fun createCore(): PureCoreFfi = PureCoreFfi()
@@ -198,4 +197,33 @@ class PureCoreFfiTest {
198197 assertTrue(view.connected)
199198 assertEquals(Screen.SESSIONS, view.screen)
200199 }
200+
201+ @Test
202+ fun `parseMessages extracts text from parts array`() {
203+ val json = """[{"id":"m1","role":"user","created":100,"parts":[{"type":"text","text":"Hello world"}]}]"""
204+ val messages = parseMessages(json)
205+ assertEquals(1, messages.size)
206+ assertEquals("m1", messages[0].id)
207+ assertEquals("user", messages[0].role)
208+ assertEquals("Hello world", messages[0].text)
209+ assertEquals(100uL, messages[0].time)
210+ }
211+
212+ @Test
213+ fun `parseMessages falls back to top-level text field`() {
214+ val json = """[{"id":"m2","role":"assistant","created":200,"text":"Fallback"}]"""
215+ val messages = parseMessages(json)
216+ assertEquals(1, messages.size)
217+ assertEquals("Fallback", messages[0].text)
218+ }
219+
220+ @Test
221+ fun `extractJsonString returns null for missing key`() {
222+ assertNull(extractJsonString("""{"a":"b"}""", "id"))
223+ }
224+
225+ @Test
226+ fun `extractJsonNumber returns null for missing key`() {
227+ assertNull(extractJsonNumber("""{"a":"b"}""", "created"))
228+ }
201229}