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
8d923ddiko
app/src/main/java/soy/iko/opencode/MainActivity.kt+25−3
@@ -1,6 +1,7 @@
1package soy.iko.opencode
1package soy.iko.opencode
2
2
3import android.os.Bundle
3import android.os.Bundle
4+import android.widget.Toast
4import androidx.activity.ComponentActivity
5import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.activity.compose.setContent
6import androidx.activity.enableEdgeToEdge
7import androidx.activity.enableEdgeToEdge
@@ -14,6 +15,10 @@ import androidx.compose.animation.slideOutHorizontally
14import androidx.compose.runtime.Composable
15import androidx.compose.runtime.Composable
15import androidx.compose.runtime.collectAsState
16import androidx.compose.runtime.collectAsState
16import androidx.compose.runtime.getValue
17import 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
17import androidx.lifecycle.viewmodel.compose.viewModel
22import androidx.lifecycle.viewmodel.compose.viewModel
18import soy.iko.opencode.core.Core
23import soy.iko.opencode.core.Core
19import soy.iko.opencode.core.Event
24import soy.iko.opencode.core.Event
@@ -38,14 +43,29 @@ class MainActivity : ComponentActivity() {
38@Composable
43@Composable
39fun App(core: Core = viewModel()) {
44fun App(core: Core = viewModel()) {
40 val view by core.view.collectAsState()
45 val view by core.view.collectAsState()
46+ val context = LocalContext.current
47+ var lastBackPress by remember { mutableLongStateOf(0L) }
41
48
42 // Map the system back button to logical in-app navigation so it never
49 // Map the system back button to logical in-app navigation so it never
43− // silently exits the app from a sub-screen.
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).
44− BackHandler(enabled = view.screen != Screen.CONNECT) {
52+ BackHandler(enabled = true) {
45 when (view.screen) {
53 when (view.screen) {
46 Screen.CHAT -> core.update(Event.NavigateToSessions)
54 Screen.CHAT -> core.update(Event.NavigateToSessions)
47 Screen.SESSIONS -> core.update(Event.NavigateToConnect)
55 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+ }
49 }
69 }
50 }
70 }
51
71
@@ -76,3 +96,5 @@ fun App(core: Core = viewModel()) {
76 }
96 }
77 }
97 }
78}
98}
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) {
97 sseClient.reconnect()
97 sseClient.reconnect()
98 }
98 }
99
99
100+ /** Surface a transient "Copied" toast/snackbar to the user. */
101+ fun notifyCopied() {
102+ viewModelScope.launch { _info.emit(SUCCESS_COPIED) }
103+ }
104+
100 private fun processEffect(request: Request) {
105 private fun processEffect(request: Request) {
101 when (val effect = request.effect) {
106 when (val effect = request.effect) {
102 is Effect.Render -> {
107 is Effect.Render -> {
@@ -218,5 +223,6 @@ class Core(application: Application) : AndroidViewModel(application) {
218
223
219 const val SUCCESS_CONNECTED = "__success_connected__"
224 const val SUCCESS_CONNECTED = "__success_connected__"
220 const val SUCCESS_SESSION_CREATED = "__success_session_created__"
225 const val SUCCESS_SESSION_CREATED = "__success_session_created__"
226+ const val SUCCESS_COPIED = "__success_copied__"
221 }
227 }
222}
228}
app/src/main/java/soy/iko/opencode/core/PureCoreFfi.kt+28−8
@@ -383,7 +383,8 @@ internal fun parseMessages(json: String): List<PureMessage> {
383 val role = extractJsonString(obj, "role") ?: ""
383 val role = extractJsonString(obj, "role") ?: ""
384 val time = extractJsonNumber(obj, "created") ?: 0uL
384 val time = extractJsonNumber(obj, "created") ?: 0uL
385 if (id.isNotEmpty() && role.isNotEmpty()) {
385 if (id.isNotEmpty() && role.isNotEmpty()) {
386+ val text = extractMessageText(obj)
386− messages.add(PureMessage(id, role, "", time))
387+ messages.add(PureMessage(id, role, text, time))
387 }
388 }
388 }
389 }
389 }
390 }
@@ -392,24 +393,43 @@ internal fun parseMessages(json: String): List<PureMessage> {
392 return messages
393 return messages
393}
394}
394
395
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+
395internal fun extractJsonString(json: String, key: String): String? {
409internal fun extractJsonString(json: String, key: String): String? {
396 val pattern = "\"$key\""
410 val pattern = "\"$key\""
397− val idx = json.indexOf(pattern) ?: return null
411+ val idx = json.indexOf(pattern)
412+ if (idx < 0) return null
398 val rest = json.substring(idx + pattern.length)
413 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
400 val rest2 = rest.substring(colon + 1)
416 val rest2 = rest.substring(colon + 1)
401− val quote = rest2.indexOf('"') ?: return null
417+ val quote = rest2.indexOf('"')
418+ if (quote < 0) return null
402 val rest3 = rest2.substring(quote + 1)
419 val rest3 = rest2.substring(quote + 1)
403− val end = rest3.indexOf('"') ?: return null
420+ val end = rest3.indexOf('"')
421+ if (end < 0) return null
404 return rest3.substring(0, end)
422 return rest3.substring(0, end)
405}
423}
406
424
407internal fun extractJsonNumber(json: String, key: String): ULong? {
425internal fun extractJsonNumber(json: String, key: String): ULong? {
408 val pattern = "\"$key\""
426 val pattern = "\"$key\""
409− val idx = json.indexOf(pattern) ?: return null
427+ val idx = json.indexOf(pattern)
428+ if (idx < 0) return null
410 val rest = json.substring(idx + pattern.length)
429 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
412 val rest2 = rest.substring(colon + 1).trimStart()
432 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 }
414 return rest2.substring(0, end).toULongOrNull()
434 return rest2.substring(0, end).toULongOrNull()
415}
435}
app/src/main/java/soy/iko/opencode/ui/components/ChatInputBar.kt+25−8
@@ -12,6 +12,7 @@ import androidx.compose.foundation.text.KeyboardActions
12import androidx.compose.foundation.text.KeyboardOptions
12import androidx.compose.foundation.text.KeyboardOptions
13import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.automirrored.filled.Send
14import androidx.compose.material.icons.automirrored.filled.Send
15+import androidx.compose.material.icons.filled.Stop
15import androidx.compose.material3.Icon
16import androidx.compose.material3.Icon
16import androidx.compose.material3.IconButton
17import androidx.compose.material3.IconButton
17import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.MaterialTheme
@@ -38,6 +39,8 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
38 * tapping the icon.
39 * tapping the icon.
39 * - Send is disabled while [enabled] is false (e.g. during loading) or when
40 * - Send is disabled while [enabled] is false (e.g. during loading) or when
40 * the text is blank.
41 * 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].
41 */
44 */
42@Composable
45@Composable
43fun ChatInputBar(
46fun ChatInputBar(
@@ -46,6 +49,9 @@ fun ChatInputBar(
46 onSend: () -> Unit,
49 onSend: () -> Unit,
47 enabled: Boolean,
50 enabled: Boolean,
48 modifier: Modifier = Modifier,
51 modifier: Modifier = Modifier,
52+ generating: Boolean = false,
53+ onStop: () -> Unit = {},
54+ stopContentDescription: String = "Stop generating",
49 placeholder: String = "Message…",
55 placeholder: String = "Message…",
50 sendContentDescription: String = "Send",
56 sendContentDescription: String = "Send",
51) {
57) {
@@ -75,14 +81,25 @@ fun ChatInputBar(
75 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
81 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
76 keyboardActions = KeyboardActions(onSend = { onSend() }),
82 keyboardActions = KeyboardActions(onSend = { onSend() }),
77 )
83 )
84+ if (generating) {
78− IconButton(
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(
79− onClick = onSend,
95+ onClick = onSend,
80− enabled = enabled && text.isNotBlank(),
96+ enabled = enabled && text.isNotBlank(),
81− modifier = Modifier.semantics {
97+ modifier = Modifier.semantics {
82− contentDescription = sendContentDescription
98+ contentDescription = sendContentDescription
83− },
99+ },
84− ) {
100+ ) {
85− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
101+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
102+ }
86 }
103 }
87 }
104 }
88 }
105 }
app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.kt+24−31
@@ -34,9 +34,12 @@ import java.io.File
34 * recomposition) rather than on every recomposition — [File.readText] is disk
34 * recomposition) rather than on every recomposition — [File.readText] is disk
35 * I/O and shouldn't run in composition repeatedly.
35 * I/O and shouldn't run in composition repeatedly.
36 *
36 *
37− * "Clear all" is a destructive action: it's placed in the dismiss slot (left,
37+ * "Clear all" is a destructive action guarded by a confirmation step. The
38− * neutral) and tinted with the error color, and a confirmation step guards
38+ * single dialog swaps its content and buttons between the list view and the
39− * against accidental taps. "Close" is the affirmative (right) action.
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)
40 */
43 */
41@Composable
44@Composable
42fun CrashLogDialog(
45fun CrashLogDialog(
@@ -47,6 +50,7 @@ fun CrashLogDialog(
47 title: String = "Crash Reports",
50 title: String = "Crash Reports",
48 clearLabel: String = "Clear all",
51 clearLabel: String = "Clear all",
49 closeLabel: String = "Close",
52 closeLabel: String = "Close",
53+ cancelLabel: String = "Cancel",
50 clearConfirmLabel: String = "Clear all crash reports?",
54 clearConfirmLabel: String = "Clear all crash reports?",
51 clearConfirmYesLabel: String = "Clear",
55 clearConfirmYesLabel: String = "Clear",
52) {
56) {
@@ -94,14 +98,26 @@ fun CrashLogDialog(
94 }
98 }
95 },
99 },
96 confirmButton = {
100 confirmButton = {
97− // Primary (right) button: "Close" is the non-destructive action.
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.
98− TextButton(onClick = onDismiss) { Text(closeLabel) }
114+ TextButton(onClick = onDismiss) { Text(closeLabel) }
115+ }
99 },
116 },
100 dismissButton = {
117 dismissButton = {
101 if (confirmingClear) {
118 if (confirmingClear) {
102− // Confirm step: the affirmative "Clear" sits on the right now,
119+ // Abort the clear.
103− // so expose a "Cancel" on the left to abort.
104− TextButton(onClick = { confirmingClear = false }) { Text(closeLabel) }
120+ TextButton(onClick = { confirmingClear = false }) { Text(cancelLabel) }
105 } else {
121 } else {
106 // Destructive action in the dismiss slot (left), tinted error.
122 // Destructive action in the dismiss slot (left), tinted error.
107 TextButton(onClick = { confirmingClear = true }) {
123 TextButton(onClick = { confirmingClear = true }) {
@@ -113,29 +129,6 @@ fun CrashLogDialog(
113 }
129 }
114 },
130 },
115 )
131 )
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− }
139}
132}
140
133
141@Preview(showBackground = true)
134@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(
72 core: Core,
72 core: Core,
73 successConnectedLabel: String,
73 successConnectedLabel: String,
74 successSessionCreatedLabel: String,
74 successSessionCreatedLabel: String,
75+ copiedLabel: String = "Copied",
75): SnackbarHostState {
76): SnackbarHostState {
76 val snackbarHostState = remember { SnackbarHostState() }
77 val snackbarHostState = remember { SnackbarHostState() }
77 LaunchedEffect(snackbarHostState) {
78 LaunchedEffect(snackbarHostState) {
@@ -79,6 +80,7 @@ fun InfoHost(
79 val message = when (key) {
80 val message = when (key) {
80 Core.SUCCESS_CONNECTED -> successConnectedLabel
81 Core.SUCCESS_CONNECTED -> successConnectedLabel
81 Core.SUCCESS_SESSION_CREATED -> successSessionCreatedLabel
82 Core.SUCCESS_SESSION_CREATED -> successSessionCreatedLabel
83+ Core.SUCCESS_COPIED -> copiedLabel
82 else -> key
84 else -> key
83 }
85 }
84 snackbarHostState.showSnackbar(
86 snackbarHostState.showSnackbar(
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+26−11
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3+import android.text.format.DateFormat
3import androidx.compose.animation.core.RepeatMode
4import androidx.compose.animation.core.RepeatMode
4import androidx.compose.animation.core.animateFloat
5import androidx.compose.animation.core.animateFloat
5import androidx.compose.animation.core.infiniteRepeatable
6import androidx.compose.animation.core.infiniteRepeatable
@@ -25,6 +26,7 @@ import androidx.compose.runtime.remember
25import androidx.compose.ui.Modifier
26import androidx.compose.ui.Modifier
26import androidx.compose.ui.hapticfeedback.HapticFeedbackType
27import androidx.compose.ui.hapticfeedback.HapticFeedbackType
27import androidx.compose.ui.platform.LocalClipboardManager
28import androidx.compose.ui.platform.LocalClipboardManager
29+import androidx.compose.ui.platform.LocalContext
28import androidx.compose.ui.platform.LocalDensity
30import androidx.compose.ui.platform.LocalDensity
29import androidx.compose.ui.platform.LocalHapticFeedback
31import androidx.compose.ui.platform.LocalHapticFeedback
30import androidx.compose.ui.platform.LocalWindowInfo
32import androidx.compose.ui.platform.LocalWindowInfo
@@ -44,9 +46,7 @@ import soy.iko.opencode.ui.theme.AssistantBubbleShape
44import soy.iko.opencode.ui.theme.Dimens
46import soy.iko.opencode.ui.theme.Dimens
45import soy.iko.opencode.ui.theme.OpencodeTheme
47import soy.iko.opencode.ui.theme.OpencodeTheme
46import soy.iko.opencode.ui.theme.UserBubbleShape
48import soy.iko.opencode.ui.theme.UserBubbleShape
47−import java.text.SimpleDateFormat
48import java.util.Date
49import java.util.Date
49−import java.util.Locale
50
50
51/**
51/**
52 * A single chat message rendered as a left/right-aligned bubble. Assistant
52 * A single chat message rendered as a left/right-aligned bubble. Assistant
@@ -66,6 +66,7 @@ import java.util.Locale
66fun MessageBubble(
66fun MessageBubble(
67 message: MessageView,
67 message: MessageView,
68 modifier: Modifier = Modifier,
68 modifier: Modifier = Modifier,
69+ onCopied: () -> Unit = {},
69) {
70) {
70 val isUser = message.role == "user"
71 val isUser = message.role == "user"
71 val maxWidth = maxBubbleWidthDp()
72 val maxWidth = maxBubbleWidthDp()
@@ -74,9 +75,11 @@ fun MessageBubble(
74 } else {
75 } else {
75 stringResource(R.string.chat_role_assistant)
76 stringResource(R.string.chat_role_assistant)
76 }
77 }
78+ val context = LocalContext.current
77− val timeLabel = remember(message.time) { formatTimestamp(message.time) }
79+ val timeLabel = remember(message.time) { formatTimestamp(message.time, context) }
78 val clipboardManager = LocalClipboardManager.current
80 val clipboardManager = LocalClipboardManager.current
79 val hapticFeedback = LocalHapticFeedback.current
81 val hapticFeedback = LocalHapticFeedback.current
82+ val onCopiedCallback = onCopied
80 // Build a single TalkBack utterance that includes the role and the
83 // Build a single TalkBack utterance that includes the role and the
81 // message body. Setting an explicit contentDescription on a merge node
84 // message body. Setting an explicit contentDescription on a merge node
82 // replaces descendant text, so we must include the body ourselves rather
85 // replaces descendant text, so we must include the body ourselves rather
@@ -98,6 +101,7 @@ fun MessageBubble(
98 if (message.text.isNotBlank()) {
101 if (message.text.isNotBlank()) {
99 clipboardManager.setText(AnnotatedString(message.text))
102 clipboardManager.setText(AnnotatedString(message.text))
100 hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
103 hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
104+ onCopiedCallback()
101 }
105 }
102 },
106 },
103 onClick = {},
107 onClick = {},
@@ -197,7 +201,8 @@ fun MessageBubble(
197@Composable
201@Composable
198fun GeneratingIndicator(modifier: Modifier = Modifier) {
202fun GeneratingIndicator(modifier: Modifier = Modifier) {
199 val maxWidth = maxBubbleWidthDp()
203 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)
201 val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
206 val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
202 // Three dots that pulse in sequence: each dot scales/fades with a
207 // Three dots that pulse in sequence: each dot scales/fades with a
203 // staggered delay so the indicator reads as "typing" rather than a
208 // staggered delay so the indicator reads as "typing" rather than a
@@ -244,7 +249,7 @@ fun GeneratingIndicator(modifier: Modifier = Modifier) {
244 ) {
249 ) {
245 Row(
250 Row(
246 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
251 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
247− horizontalArrangement = Arrangement.spacedBy(Dimens.spaceTiny),
252+ horizontalArrangement = Arrangement.spacedBy(Dimens.spaceSmall),
248 modifier = Modifier
253 modifier = Modifier
249 .padding(
254 .padding(
250 horizontal = Dimens.bubbleHorizontalPadding,
255 horizontal = Dimens.bubbleHorizontalPadding,
@@ -253,12 +258,22 @@ fun GeneratingIndicator(modifier: Modifier = Modifier) {
253 .semantics(mergeDescendants = true) {
258 .semantics(mergeDescendants = true) {
254 // Announced when the indicator appears/disappears.
259 // Announced when the indicator appears/disappears.
255 liveRegion = LiveRegionMode.Polite
260 liveRegion = LiveRegionMode.Polite
256− contentDescription = generatingLabel
261+ contentDescription = cdLabel
257 },
262 },
258 ) {
263 ) {
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+ ) {
259− GeneratingDot(alpha = phase1, color = dotColor)
273+ GeneratingDot(alpha = phase1, color = dotColor)
260− GeneratingDot(alpha = phase2, color = dotColor)
274+ GeneratingDot(alpha = phase2, color = dotColor)
261− GeneratingDot(alpha = phase3, color = dotColor)
275+ GeneratingDot(alpha = phase3, color = dotColor)
276+ }
262 }
277 }
263 }
278 }
264 }
279 }
@@ -281,9 +296,9 @@ private fun GeneratingDot(alpha: Float, color: androidx.compose.ui.graphics.Colo
281 }
296 }
282}
297}
283
298
284−private fun formatTimestamp(epochSeconds: ULong): String {
299+private fun formatTimestamp(epochSeconds: ULong, context: android.content.Context): String {
285 val date = Date(epochSeconds.toLong() * 1000)
300 val date = Date(epochSeconds.toLong() * 1000)
286− val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
301+ val formatter = DateFormat.getTimeFormat(context)
287 return formatter.format(date)
302 return formatter.format(date)
288}
303}
289
304
app/src/main/java/soy/iko/opencode/ui/components/SseDisconnectedBanner.kt+10−3
@@ -26,8 +26,9 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
26
26
27/**
27/**
28 * A thin banner shown above the message list when the SSE live stream drops.
28 * 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
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
30− * readers announce the disconnection once.
31+ * region so screen readers announce the disconnection once.
31 *
32 *
32 * Only render this when [SseState] is [SseState.Error] or an unexpected
33 * Only render this when [SseState] is [SseState.Error] or an unexpected
33 * [SseState.Disconnected] while a chat is active.
34 * [SseState.Disconnected] while a chat is active.
@@ -36,8 +37,10 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
36fun SseDisconnectedBanner(
37fun SseDisconnectedBanner(
37 message: String,
38 message: String,
38 onReconnect: () -> Unit,
39 onReconnect: () -> Unit,
40+ onDismiss: () -> Unit,
39 modifier: Modifier = Modifier,
41 modifier: Modifier = Modifier,
40 reconnectLabel: String = "Reconnect",
42 reconnectLabel: String = "Reconnect",
43+ dismissLabel: String = "Dismiss",
41) {
44) {
42 Surface(
45 Surface(
43 color = MaterialTheme.colorScheme.errorContainer,
46 color = MaterialTheme.colorScheme.errorContainer,
@@ -71,7 +74,10 @@ fun SseDisconnectedBanner(
71 maxLines = 2,
74 maxLines = 2,
72 )
75 )
73 }
76 }
77+ Row(verticalAlignment = Alignment.CenterVertically) {
78+ TextButton(onClick = onDismiss) { Text(dismissLabel) }
74− TextButton(onClick = onReconnect) { Text(reconnectLabel) }
79+ TextButton(onClick = onReconnect) { Text(reconnectLabel) }
80+ }
75 }
81 }
76 }
82 }
77}
83}
@@ -83,6 +89,7 @@ private fun SseDisconnectedBannerPreview() {
83 SseDisconnectedBanner(
89 SseDisconnectedBanner(
84 message = "Live stream disconnected",
90 message = "Live stream disconnected",
85 onReconnect = {},
91 onReconnect = {},
92+ onDismiss = {},
86 )
93 )
87 }
94 }
88}
95}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+45−6
@@ -50,6 +50,7 @@ import soy.iko.opencode.core.Event
50import soy.iko.opencode.core.MessageView
50import soy.iko.opencode.core.MessageView
51import soy.iko.opencode.core.SseState
51import soy.iko.opencode.core.SseState
52import soy.iko.opencode.ui.components.ChatInputBar
52import soy.iko.opencode.ui.components.ChatInputBar
53+import soy.iko.opencode.ui.components.DateSeparator
53import soy.iko.opencode.ui.components.DualSnackbarHost
54import soy.iko.opencode.ui.components.DualSnackbarHost
54import soy.iko.opencode.ui.components.EmptyState
55import soy.iko.opencode.ui.components.EmptyState
55import soy.iko.opencode.ui.components.ErrorHost
56import soy.iko.opencode.ui.components.ErrorHost
@@ -58,6 +59,7 @@ import soy.iko.opencode.ui.components.InfoHost
58import soy.iko.opencode.ui.components.LoadingPlaceholder
59import soy.iko.opencode.ui.components.LoadingPlaceholder
59import soy.iko.opencode.ui.components.MessageBubble
60import soy.iko.opencode.ui.components.MessageBubble
60import soy.iko.opencode.ui.components.SseDisconnectedBanner
61import soy.iko.opencode.ui.components.SseDisconnectedBanner
62+import soy.iko.opencode.ui.components.dayOfEpoch
61import soy.iko.opencode.ui.theme.Dimens
63import soy.iko.opencode.ui.theme.Dimens
62import soy.iko.opencode.ui.theme.OpencodeTheme
64import soy.iko.opencode.ui.theme.OpencodeTheme
63
65
@@ -70,6 +72,7 @@ fun ChatScreen(core: Core) {
70 val listState = rememberLazyListState()
72 val listState = rememberLazyListState()
71 var scrollRequest by remember { mutableIntStateOf(0) }
73 var scrollRequest by remember { mutableIntStateOf(0) }
72 var menuExpanded by remember { mutableStateOf(false) }
74 var menuExpanded by remember { mutableStateOf(false) }
75+ var sseBannerDismissed by remember { mutableStateOf(false) }
73 val inputFocusRequester = remember { FocusRequester() }
76 val inputFocusRequester = remember { FocusRequester() }
74
77
75 // Track whether the user is parked at the bottom of the list. We only
78 // Track whether the user is parked at the bottom of the list. We only
@@ -118,10 +121,18 @@ fun ChatScreen(core: Core) {
118 core = core,
121 core = core,
119 successConnectedLabel = stringResource(R.string.connect_success),
122 successConnectedLabel = stringResource(R.string.connect_success),
120 successSessionCreatedLabel = stringResource(R.string.session_created),
123 successSessionCreatedLabel = stringResource(R.string.session_created),
124+ copiedLabel = stringResource(R.string.chat_copied),
121 )
125 )
122
126
123− val showSseBanner = sseState is SseState.Error ||
127+ val showSseBanner = (sseState is SseState.Error ||
124− (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
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+ }
125
136
126 // Resolve nullable session fields into locals once per recomposition so we
137 // Resolve nullable session fields into locals once per recomposition so we
127 // never smart-cast a delegate-backed collected property across calls.
138 // never smart-cast a delegate-backed collected property across calls.
@@ -219,6 +230,9 @@ fun ChatScreen(core: Core) {
219 // Disable while loading OR while the assistant is generating,
230 // Disable while loading OR while the assistant is generating,
220 // so users can't fire a second message mid-reply.
231 // so users can't fire a second message mid-reply.
221 enabled = !view.loading && !view.generating,
232 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),
222 placeholder = stringResource(R.string.chat_input_placeholder),
236 placeholder = stringResource(R.string.chat_input_placeholder),
223 sendContentDescription = stringResource(R.string.chat_cd_send),
237 sendContentDescription = stringResource(R.string.chat_cd_send),
224 modifier = Modifier.focusRequester(inputFocusRequester),
238 modifier = Modifier.focusRequester(inputFocusRequester),
@@ -237,8 +251,13 @@ fun ChatScreen(core: Core) {
237 ?: stringResource(R.string.sse_disconnected)
251 ?: stringResource(R.string.sse_disconnected)
238 SseDisconnectedBanner(
252 SseDisconnectedBanner(
239 message = bannerMessage,
253 message = bannerMessage,
254+ onReconnect = {
255+ sseBannerDismissed = false
240− onReconnect = { core.reconnectSse() },
256+ core.reconnectSse()
257+ },
258+ onDismiss = { sseBannerDismissed = true },
241 reconnectLabel = stringResource(R.string.sse_reconnect),
259 reconnectLabel = stringResource(R.string.sse_reconnect),
260+ dismissLabel = stringResource(R.string.sse_dismiss),
242 )
261 )
243 }
262 }
244
263
@@ -266,8 +285,28 @@ fun ChatScreen(core: Core) {
266 verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
285 verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
267 contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
286 contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
268 ) {
287 ) {
269− items(view.messages, key = { it.id }) { 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) {
270− MessageBubble(message)
305+ MessageBubble(
306+ message = msg,
307+ onCopied = { core.notifyCopied() },
308+ )
309+ }
271 }
310 }
272 // "Thinking" indicator shown after the user sends, while
311 // "Thinking" indicator shown after the user sends, while
273 // the assistant reply hasn't arrived yet. The indicator
312 // the assistant reply hasn't arrived yet. The indicator
@@ -303,7 +342,7 @@ fun ChatScreen(core: Core) {
303 contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
342 contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
304 )
343 )
305 },
344 },
306− text = { Text(stringResource(R.string.chat_cd_scroll_to_bottom)) },
345+ text = { Text(stringResource(R.string.chat_scroll_to_latest)) },
307 containerColor = MaterialTheme.colorScheme.primaryContainer,
346 containerColor = MaterialTheme.colorScheme.primaryContainer,
308 contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
347 contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
309 modifier = Modifier
348 modifier = Modifier
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+1
@@ -283,6 +283,7 @@ fun ConnectScreen(core: Core) {
283 title = stringResource(R.string.crash_reports_title),
283 title = stringResource(R.string.crash_reports_title),
284 clearLabel = stringResource(R.string.crash_reports_clear),
284 clearLabel = stringResource(R.string.crash_reports_clear),
285 closeLabel = stringResource(R.string.crash_reports_close),
285 closeLabel = stringResource(R.string.crash_reports_close),
286+ cancelLabel = stringResource(R.string.crash_reports_cancel),
286 clearConfirmLabel = stringResource(R.string.crash_reports_clear_confirm),
287 clearConfirmLabel = stringResource(R.string.crash_reports_clear_confirm),
287 clearConfirmYesLabel = stringResource(R.string.crash_reports_clear_confirm_yes),
288 clearConfirmYesLabel = stringResource(R.string.crash_reports_clear_confirm_yes),
288 )
289 )
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+8
@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.LazyColumn
8import androidx.compose.foundation.lazy.items
8import androidx.compose.foundation.lazy.items
9import androidx.compose.material.icons.Icons
9import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.automirrored.filled.ArrowBack
10import androidx.compose.material.icons.automirrored.filled.ArrowBack
11+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
11import androidx.compose.material.icons.filled.Add
12import androidx.compose.material.icons.filled.Add
12import androidx.compose.material.icons.filled.ChatBubble
13import androidx.compose.material.icons.filled.ChatBubble
13import androidx.compose.material3.ExperimentalMaterial3Api
14import androidx.compose.material3.ExperimentalMaterial3Api
@@ -136,6 +137,13 @@ private fun SessionRow(
136 tint = MaterialTheme.colorScheme.primary,
137 tint = MaterialTheme.colorScheme.primary,
137 )
138 )
138 },
139 },
140+ trailingContent = {
141+ Icon(
142+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
143+ contentDescription = null,
144+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
145+ )
146+ },
139 modifier = Modifier
147 modifier = Modifier
140 .clickable(onClick = onClick)
148 .clickable(onClick = onClick)
141 .semantics(mergeDescendants = true) {
149 .semantics(mergeDescendants = true) {
app/src/main/res/values-night-v31/themes.xml+3−2
@@ -1,7 +1,8 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<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>
4− <item name="android:windowSplashScreenBackground">@android:color/black</item>
5+ <item name="android:windowSplashScreenBackground">@color/splash_background</item>
5 <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
6 <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
6 </style>
7 </style>
7</resources>
8</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 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<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>
4</resources>
6</resources>
app/src/main/res/values-v31/themes.xml+3−2
@@ -1,11 +1,12 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<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>
4 <!-- Android 12+ system splash screen: use the window background color
5 <!-- Android 12+ system splash screen: use the window background color
5 so the post-splash crossfade into the Compose UI is seamless. The
6 so the post-splash crossfade into the Compose UI is seamless. The
6 Compose UI paints its own background, so we only set the poster
7 Compose UI paints its own background, so we only set the poster
7 color and a system icon here. -->
8 color and a system icon here. -->
8− <item name="android:windowSplashScreenBackground">@android:color/white</item>
9+ <item name="android:windowSplashScreenBackground">@color/splash_background</item>
9 <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
10 <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
10 </style>
11 </style>
11</resources>
12</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 @@
26 <string name="crash_reports_title">Crash Reports</string>
26 <string name="crash_reports_title">Crash Reports</string>
27 <plurals name="crash_reports_view">
27 <plurals name="crash_reports_view">
28 <item quantity="one">%d crash report — view</item>
28 <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>
30 </plurals>
30 </plurals>
31 <string name="crash_reports_clear">Clear all</string>
31 <string name="crash_reports_clear">Clear all</string>
32 <string name="crash_reports_close">Close</string>
32 <string name="crash_reports_close">Close</string>
33+ <string name="crash_reports_cancel">Cancel</string>
33 <string name="crash_reports_clear_confirm">Clear all crash reports?</string>
34 <string name="crash_reports_clear_confirm">Clear all crash reports?</string>
34 <string name="crash_reports_clear_confirm_yes">Clear</string>
35 <string name="crash_reports_clear_confirm_yes">Clear</string>
35
36
@@ -50,7 +51,11 @@
50 <string name="chat_role_user">User</string>
51 <string name="chat_role_user">User</string>
51 <string name="chat_role_assistant">Assistant</string>
52 <string name="chat_role_assistant">Assistant</string>
52 <string name="chat_cd_generating">Generating reply</string>
53 <string name="chat_cd_generating">Generating reply</string>
54+ <string name="chat_generating_label">Thinking…</string>
53 <string name="chat_cd_scroll_to_bottom">Scroll to latest message</string>
55 <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>
54 <string name="chat_session_id_short">Session %s</string>
59 <string name="chat_session_id_short">Session %s</string>
55 <string name="chat_menu_overflow">More options</string>
60 <string name="chat_menu_overflow">More options</string>
56 <string name="chat_menu_refresh">Refresh messages</string>
61 <string name="chat_menu_refresh">Refresh messages</string>
@@ -62,4 +67,6 @@
62 <!-- SSE / connectivity -->
67 <!-- SSE / connectivity -->
63 <string name="sse_disconnected">Live stream disconnected</string>
68 <string name="sse_disconnected">Live stream disconnected</string>
64 <string name="sse_reconnect">Reconnect</string>
69 <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>
65</resources>
72</resources>
app/src/main/res/values/themes.xml+8−1
@@ -1,4 +1,11 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<resources>
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. -->
3− <style name="Theme.Opencode" parent="android:Theme.Material.Light.NoActionBar" />
8+ <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.Light.NoActionBar">
9+ <item name="android:windowBackground">@color/splash_background</item>
10+ </style>
4</resources>
11</resources>
app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.kt+29−1
@@ -6,7 +6,6 @@ import org.junit.Assert.assertNotNull
6import org.junit.Assert.assertNull
6import org.junit.Assert.assertNull
7import org.junit.Assert.assertTrue
7import org.junit.Assert.assertTrue
8import org.junit.Test
8import org.junit.Test
9−
10class PureCoreFfiTest {
9class PureCoreFfiTest {
11
10
12 private fun createCore(): PureCoreFfi = PureCoreFfi()
11 private fun createCore(): PureCoreFfi = PureCoreFfi()
@@ -198,4 +197,33 @@ class PureCoreFfiTest {
198 assertTrue(view.connected)
197 assertTrue(view.connected)
199 assertEquals(Screen.SESSIONS, view.screen)
198 assertEquals(Screen.SESSIONS, view.screen)
200 }
199 }
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+ }
201}
229}