git.iko.soy/opencode-android
on master help sign in
Add session delete/rename, draft persistence, real cancel-generation, message status, SSE connecting banner, copy session ID, crash report copy/share, Today/Yesterday date labels, newline input, haptic on send, rememberSaveable across rotation, disconnect confirmation, WCAG-AA tertiary color fix, localized loading label, and exit snackbar
aed5cefiko
app/src/main/java/soy/iko/opencode/MainActivity.kt+44−25
@@ -1,7 +1,6 @@
1package soy.iko.opencode
1package soy.iko.opencode
2
2
3import android.os.Bundle
3import android.os.Bundle
4−import android.widget.Toast
5import androidx.activity.ComponentActivity
4import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
5import androidx.activity.compose.setContent
7import androidx.activity.enableEdgeToEdge
6import androidx.activity.enableEdgeToEdge
@@ -12,13 +11,20 @@ import androidx.compose.animation.fadeIn
12import androidx.compose.animation.fadeOut
11import androidx.compose.animation.fadeOut
13import androidx.compose.animation.slideInHorizontally
12import androidx.compose.animation.slideInHorizontally
14import androidx.compose.animation.slideOutHorizontally
13import androidx.compose.animation.slideOutHorizontally
14+import androidx.compose.material3.SnackbarDuration
15+import androidx.compose.material3.SnackbarHost
16+import androidx.compose.material3.SnackbarHostState
15import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.LaunchedEffect
16import androidx.compose.runtime.collectAsState
19import androidx.compose.runtime.collectAsState
17import androidx.compose.runtime.getValue
20import androidx.compose.runtime.getValue
18import androidx.compose.runtime.mutableLongStateOf
21import androidx.compose.runtime.mutableLongStateOf
19import androidx.compose.runtime.remember
22import androidx.compose.runtime.remember
20import androidx.compose.runtime.setValue
23import androidx.compose.runtime.setValue
24+import androidx.compose.foundation.layout.padding
25+import androidx.compose.ui.Modifier
21import androidx.compose.ui.platform.LocalContext
26import androidx.compose.ui.platform.LocalContext
27+import androidx.compose.ui.res.stringResource
22import androidx.lifecycle.viewmodel.compose.viewModel
28import androidx.lifecycle.viewmodel.compose.viewModel
23import soy.iko.opencode.core.Core
29import soy.iko.opencode.core.Core
24import soy.iko.opencode.core.Event
30import soy.iko.opencode.core.Event
@@ -45,10 +51,12 @@ fun App(core: Core = viewModel()) {
45 val view by core.view.collectAsState()
51 val view by core.view.collectAsState()
46 val context = LocalContext.current
52 val context = LocalContext.current
47 var lastBackPress by remember { mutableLongStateOf(0L) }
53 var lastBackPress by remember { mutableLongStateOf(0L) }
54+ val snackbarHostState = remember { SnackbarHostState() }
55+ val exitHint = stringResource(R.string.press_back_again_to_exit)
48
56
49 // Map the system back button to logical in-app navigation so it never
57 // Map the system back button to logical in-app navigation so it never
50 // silently exits the app from a sub-screen. On the root Connect screen,
58 // 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).
59+ // require a double press to exit (with a snackbar hint the first time).
52 BackHandler(enabled = true) {
60 BackHandler(enabled = true) {
53 when (view.screen) {
61 when (view.screen) {
54 Screen.CHAT -> core.update(Event.NavigateToSessions)
62 Screen.CHAT -> core.update(Event.NavigateToSessions)
@@ -59,42 +67,53 @@ fun App(core: Core = viewModel()) {
59 (context as? android.app.Activity)?.finish()
67 (context as? android.app.Activity)?.finish()
60 } else {
68 } else {
61 lastBackPress = now
69 lastBackPress = now
62− Toast.makeText(
63− context,
64− R.string.press_back_again_to_exit,
65− Toast.LENGTH_SHORT,
66− ).show()
67 }
70 }
68 }
71 }
69 }
72 }
70 }
73 }
71
74
75+ // Show the "press back again to exit" hint via a snackbar instead of a
76+ // toast, for consistency with the rest of the app's feedback.
77+ LaunchedEffect(lastBackPress) {
78+ if (lastBackPress > 0L && view.screen == Screen.CONNECT) {
79+ snackbarHostState.showSnackbar(
80+ message = exitHint,
81+ duration = SnackbarDuration.Short,
82+ )
83+ }
84+ }
85+
72 // Directional slide between screens for a smoother navigation feel:
86 // Directional slide between screens for a smoother navigation feel:
73 // forward navigation slides the new screen in from the end side, back
87 // forward navigation slides the new screen in from the end side, back
74 // navigation slides it in from the start side. A short fade is layered on
88 // navigation slides it in from the start side. A short fade is layered on
75 // so the transition doesn't feel harsh. The TopAppBar still fades with
89 // so the transition doesn't feel harsh. The TopAppBar still fades with
76 // each screen, but the directional motion reads as natural page changes
90 // each screen, but the directional motion reads as natural page changes
77 // rather than a flat crossfade.
91 // rather than a flat crossfade.
92+ androidx.compose.material3.Scaffold(
93+ snackbarHost = { SnackbarHost(snackbarHostState) },
94+ ) { padding ->
78− AnimatedContent(
95+ AnimatedContent(
79− targetState = view.screen,
96+ targetState = view.screen,
80− transitionSpec = {
97+ transitionSpec = {
81− val forward = targetState.ordinal > initialState.ordinal
98+ val forward = targetState.ordinal > initialState.ordinal
82− if (forward) {
99+ if (forward) {
83− slideInHorizontally(initialOffsetX = { it / 3 }) + fadeIn() togetherWith
100+ slideInHorizontally(initialOffsetX = { it / 3 }) + fadeIn() togetherWith
84− slideOutHorizontally(targetOffsetX = { -it / 6 }) + fadeOut()
101+ slideOutHorizontally(targetOffsetX = { -it / 6 }) + fadeOut()
85− } else {
102+ } else {
86− slideInHorizontally(initialOffsetX = { -it / 6 }) + fadeIn() togetherWith
103+ slideInHorizontally(initialOffsetX = { -it / 6 }) + fadeIn() togetherWith
87− slideOutHorizontally(targetOffsetX = { it / 3 }) + fadeOut()
104+ slideOutHorizontally(targetOffsetX = { it / 3 }) + fadeOut()
105+ }
106+ },
107+ label = "screen-transition",
108+ modifier = Modifier.padding(padding),
109+ ) { screen ->
110+ when (screen) {
111+ Screen.CONNECT -> ConnectScreen(core)
112+ Screen.SESSIONS -> SessionsScreen(core)
113+ Screen.CHAT -> ChatScreen(core)
88 }
114 }
89− },
90− label = "screen-transition",
91− ) { screen ->
92− when (screen) {
93− Screen.CONNECT -> ConnectScreen(core)
94− Screen.SESSIONS -> SessionsScreen(core)
95− Screen.CHAT -> ChatScreen(core)
96 }
115 }
97 }
116 }
98}
117}
99
118
100−private const val EXIT_CONFIRMATION_MS = 2000L
119+private val EXIT_CONFIRMATION_MS = 2000L
app/src/main/java/soy/iko/opencode/core/Core.kt+11−1
@@ -102,6 +102,11 @@ class Core(application: Application) : AndroidViewModel(application) {
102 viewModelScope.launch { _info.emit(SUCCESS_COPIED) }
102 viewModelScope.launch { _info.emit(SUCCESS_COPIED) }
103 }
103 }
104
104
105+ /** Surface a transient "Session deleted" snackbar to the user. */
106+ fun notifyDeleted() {
107+ viewModelScope.launch { _info.emit(SUCCESS_SESSION_DELETED) }
108+ }
109+
105 private fun processEffect(request: Request) {
110 private fun processEffect(request: Request) {
106 when (val effect = request.effect) {
111 when (val effect = request.effect) {
107 is Effect.Render -> {
112 is Effect.Render -> {
@@ -149,6 +154,9 @@ class Core(application: Application) : AndroidViewModel(application) {
149 lastUserEvent is Event.CreateSession -> {
154 lastUserEvent is Event.CreateSession -> {
150 viewModelScope.launch { _info.emit(SUCCESS_SESSION_CREATED) }
155 viewModelScope.launch { _info.emit(SUCCESS_SESSION_CREATED) }
151 }
156 }
157+ lastUserEvent is Event.DeleteSession && v.error == null -> {
158+ viewModelScope.launch { _info.emit(SUCCESS_SESSION_DELETED) }
159+ }
152 }
160 }
153 previousScreen = v.screen
161 previousScreen = v.screen
154 }
162 }
@@ -213,7 +221,8 @@ class Core(application: Application) : AndroidViewModel(application) {
213 is Event.LoadSessions,
221 is Event.LoadSessions,
214 is Event.CreateSession,
222 is Event.CreateSession,
215 is Event.LoadMessages,
223 is Event.LoadMessages,
216− is Event.SendMessage -> true
224+ is Event.SendMessage,
225+ is Event.DeleteSession -> true
217 else -> false
226 else -> false
218 }
227 }
219
228
@@ -224,5 +233,6 @@ class Core(application: Application) : AndroidViewModel(application) {
224 const val SUCCESS_CONNECTED = "__success_connected__"
233 const val SUCCESS_CONNECTED = "__success_connected__"
225 const val SUCCESS_SESSION_CREATED = "__success_session_created__"
234 const val SUCCESS_SESSION_CREATED = "__success_session_created__"
226 const val SUCCESS_COPIED = "__success_copied__"
235 const val SUCCESS_COPIED = "__success_copied__"
236+ const val SUCCESS_SESSION_DELETED = "__success_session_deleted__"
227 }
237 }
228}
238}
app/src/main/java/soy/iko/opencode/core/PureCoreFfi.kt+62−14
@@ -165,7 +165,30 @@ internal data class PureModel(
165 is Event.DismissError -> {
165 is Event.DismissError -> {
166 error = null
166 error = null
167 }
167 }
168− else -> {}
168+ is Event.DraftChanged -> {
169+ draftMessage = event.value
170+ }
171+ is Event.CancelGeneration -> {
172+ generating = false
173+ }
174+ is Event.DeleteSession -> {
175+ // Optimistically remove the session.
176+ sessions.removeAll { it.id == event.value }
177+ if (currentSessionId == event.value) {
178+ currentSessionId = null
179+ currentSessionTitle = ""
180+ messages.clear()
181+ generating = false
182+ }
183+ val id = nextId++
184+ pendingRequests[id] = PendingRequest.DeleteSession(event.value)
185+ requests.add(
186+ Request(id, Effect.Http(makeDeleteRequest("${serverUrl}/session/${event.value}"))),
187+ )
188+ }
189+ is Event.RetryMessage -> {
190+ // No-op in the pure fallback; real retry needs the original body.
191+ }
169 }
192 }
170 return Requests(requests)
193 return Requests(requests)
171 }
194 }
@@ -274,6 +297,22 @@ internal data class PureModel(
274 }
297 }
275 }
298 }
276 }
299 }
300+ is PendingRequest.DeleteSession -> {
301+ when (result) {
302+ is HttpResult.Ok -> {
303+ // Optimistic removal already happened; nothing to do.
304+ }
305+ is HttpResult.Err -> {
306+ // Restore by reloading sessions.
307+ val newId = nextId++
308+ pendingRequests[newId] = PendingRequest.LoadSessions
309+ requests.add(
310+ Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session"))),
311+ )
312+ error = "Failed to delete session: ${result.value}"
313+ }
314+ }
315+ }
277 }
316 }
278 return Requests(requests)
317 return Requests(requests)
279 }
318 }
@@ -296,7 +335,7 @@ internal data class PureModel(
296 sessions = sessions.map { SessionView(it.id, it.title) },
335 sessions = sessions.map { SessionView(it.id, it.title) },
297 currentSessionId = currentSessionId,
336 currentSessionId = currentSessionId,
298 currentSessionTitle = currentSessionTitle,
337 currentSessionTitle = currentSessionTitle,
299− messages = messages.map { MessageView(it.id, it.role, it.text, it.time) },
338+ messages = messages.map { MessageView(it.id, it.role, it.text, it.time, it.status) },
300 draftMessage = draftMessage,
339 draftMessage = draftMessage,
301 generating = generating,
340 generating = generating,
302 crashLogCount = crashLogs.size.toUInt(),
341 crashLogCount = crashLogs.size.toUInt(),
@@ -305,26 +344,28 @@ internal data class PureModel(
305 }
344 }
306
345
307 private fun makeGetRequest(url: String): HttpRequest {
346 private fun makeGetRequest(url: String): HttpRequest {
308− val headers = if (username.isNotEmpty() || password.isNotEmpty()) {
347+ val headers = authHeaders()
309− val creds = "$username:$password"
310− val encoded = java.util.Base64.getEncoder().encodeToString(creds.toByteArray())
311− listOf(HttpHeader("Authorization", "Basic $encoded"))
312− } else {
313− emptyList()
314− }
315 return HttpRequest(method = "GET", url = url, headers = headers, body = Bytes(ByteArray(0)))
348 return HttpRequest(method = "GET", url = url, headers = headers, body = Bytes(ByteArray(0)))
316 }
349 }
317
350
318 private fun makePostRequest(url: String, body: ByteArray): HttpRequest {
351 private fun makePostRequest(url: String, body: ByteArray): HttpRequest {
352+ val headers = authHeaders()
353+ return HttpRequest(method = "POST", url = url, headers = headers, body = Bytes(body))
354+ }
355+
356+ private fun makeDeleteRequest(url: String): HttpRequest {
357+ val headers = authHeaders()
358+ return HttpRequest(method = "DELETE", url = url, headers = headers, body = Bytes(ByteArray(0)))
359+ }
360+
361+ private fun authHeaders(): List<HttpHeader> =
319− val headers = if (username.isNotEmpty() || password.isNotEmpty()) {
362+ if (username.isNotEmpty() || password.isNotEmpty()) {
320 val creds = "$username:$password"
363 val creds = "$username:$password"
321 val encoded = java.util.Base64.getEncoder().encodeToString(creds.toByteArray())
364 val encoded = java.util.Base64.getEncoder().encodeToString(creds.toByteArray())
322 listOf(HttpHeader("Authorization", "Basic $encoded"))
365 listOf(HttpHeader("Authorization", "Basic $encoded"))
323 } else {
366 } else {
324 emptyList()
367 emptyList()
325 }
368 }
326− return HttpRequest(method = "POST", url = url, headers = headers, body = Bytes(body))
327− }
328}
369}
329
370
330internal sealed class PendingRequest {
371internal sealed class PendingRequest {
@@ -333,11 +374,18 @@ internal sealed class PendingRequest {
333 data object CreateSession : PendingRequest()
374 data object CreateSession : PendingRequest()
334 data class LoadMessages(val sessionId: String) : PendingRequest()
375 data class LoadMessages(val sessionId: String) : PendingRequest()
335 data class SendMessage(val sessionId: String) : PendingRequest()
376 data class SendMessage(val sessionId: String) : PendingRequest()
377+ data class DeleteSession(val sessionId: String) : PendingRequest()
336}
378}
337
379
338internal data class PureSession(val id: String, val title: String)
380internal data class PureSession(val id: String, val title: String)
339
381
340−internal data class PureMessage(val id: String, val role: String, val text: String, val time: ULong)
382+internal data class PureMessage(
383+ val id: String,
384+ val role: String,
385+ val text: String,
386+ val time: ULong,
387+ val status: MessageStatus = MessageStatus.SENT,
388+)
341
389
342internal fun parseSessions(json: String): List<PureSession> {
390internal fun parseSessions(json: String): List<PureSession> {
343 val sessions = mutableListOf<PureSession>()
391 val sessions = mutableListOf<PureSession>()
@@ -384,7 +432,7 @@ internal fun parseMessages(json: String): List<PureMessage> {
384 val time = extractJsonNumber(obj, "created") ?: 0uL
432 val time = extractJsonNumber(obj, "created") ?: 0uL
385 if (id.isNotEmpty() && role.isNotEmpty()) {
433 if (id.isNotEmpty() && role.isNotEmpty()) {
386 val text = extractMessageText(obj)
434 val text = extractMessageText(obj)
387− messages.add(PureMessage(id, role, text, time))
435+ messages.add(PureMessage(id, role, text, time, MessageStatus.SENT))
388 }
436 }
389 }
437 }
390 }
438 }
app/src/main/java/soy/iko/opencode/ui/components/ChatInputBar.kt+16−6
@@ -22,6 +22,8 @@ import androidx.compose.material3.Text
22import androidx.compose.runtime.Composable
22import androidx.compose.runtime.Composable
23import androidx.compose.ui.Alignment
23import androidx.compose.ui.Alignment
24import androidx.compose.ui.Modifier
24import androidx.compose.ui.Modifier
25+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
26+import androidx.compose.ui.platform.LocalHapticFeedback
25import androidx.compose.ui.semantics.contentDescription
27import androidx.compose.ui.semantics.contentDescription
26import androidx.compose.ui.semantics.semantics
28import androidx.compose.ui.semantics.semantics
27import androidx.compose.ui.text.input.ImeAction
29import androidx.compose.ui.text.input.ImeAction
@@ -35,12 +37,14 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
35 *
37 *
36 * - Honors IME insets via [Modifier.imePadding] so the keyboard never covers
38 * - Honors IME insets via [Modifier.imePadding] so the keyboard never covers
37 * the input bar (the chat Scaffold relies on this).
39 * the input bar (the chat Scaffold relies on this).
38− * - Exposes a keyboard "Send" action so users can fire-and-forget without
40+ * - Uses [ImeAction.Default] so the keyboard's action key inserts a newline,
39− * tapping the icon.
41+ * allowing multi-line messages. The send button on the right fires the
42+ * message.
40 * - Send is disabled while [enabled] is false (e.g. during loading) or when
43 * - Send is disabled while [enabled] is false (e.g. during loading) or when
41 * the text is blank.
44 * the text is blank.
42 * - When [generating] is true, a Stop button replaces the Send button so the
45 * - When [generating] is true, a Stop button replaces the Send button so the
43 * user can cancel the wait; tapping it calls [onStop].
46 * user can cancel the wait; tapping it calls [onStop].
47+ * - A long-press haptic fires on send for tactile confirmation.
44 */
48 */
45@Composable
49@Composable
46fun ChatInputBar(
50fun ChatInputBar(
@@ -55,6 +59,7 @@ fun ChatInputBar(
55 placeholder: String = "Message…",
59 placeholder: String = "Message…",
56 sendContentDescription: String = "Send",
60 sendContentDescription: String = "Send",
57) {
61) {
62+ val hapticFeedback = LocalHapticFeedback.current
58 Surface(
63 Surface(
59 tonalElevation = Dimens.inputBarTonalElevation,
64 tonalElevation = Dimens.inputBarTonalElevation,
60 modifier = modifier
65 modifier = modifier
@@ -78,12 +83,14 @@ fun ChatInputBar(
78 placeholder = { Text(placeholder) },
83 placeholder = { Text(placeholder) },
79 modifier = Modifier.weight(1f),
84 modifier = Modifier.weight(1f),
80 maxLines = 4,
85 maxLines = 4,
81− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
86+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default),
82− keyboardActions = KeyboardActions(onSend = { onSend() }),
83 )
87 )
84 if (generating) {
88 if (generating) {
85 IconButton(
89 IconButton(
86− onClick = onStop,
90+ onClick = {
91+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
92+ onStop()
93+ },
87 modifier = Modifier.semantics {
94 modifier = Modifier.semantics {
88 contentDescription = stopContentDescription
95 contentDescription = stopContentDescription
89 },
96 },
@@ -92,7 +99,10 @@ fun ChatInputBar(
92 }
99 }
93 } else {
100 } else {
94 IconButton(
101 IconButton(
95− onClick = onSend,
102+ onClick = {
103+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
104+ onSend()
105+ },
96 enabled = enabled && text.isNotBlank(),
106 enabled = enabled && text.isNotBlank(),
97 modifier = Modifier.semantics {
107 modifier = Modifier.semantics {
98 contentDescription = sendContentDescription
108 contentDescription = sendContentDescription
app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.kt+72−11
@@ -1,11 +1,14 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3+import android.content.Intent
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.Spacer
5import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.height
8import androidx.compose.foundation.layout.height
7import androidx.compose.foundation.layout.heightIn
9import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.rememberScrollState
10import androidx.compose.foundation.verticalScroll
13import androidx.compose.foundation.verticalScroll
11import androidx.compose.material3.AlertDialog
14import androidx.compose.material3.AlertDialog
@@ -13,13 +16,19 @@ import androidx.compose.material3.MaterialTheme
13import androidx.compose.material3.Text
16import androidx.compose.material3.Text
14import androidx.compose.material3.TextButton
17import androidx.compose.material3.TextButton
15import androidx.compose.runtime.Composable
18import androidx.compose.runtime.Composable
19+import androidx.compose.runtime.LaunchedEffect
16import androidx.compose.runtime.getValue
20import androidx.compose.runtime.getValue
17import androidx.compose.runtime.mutableStateOf
21import androidx.compose.runtime.mutableStateOf
18import androidx.compose.runtime.remember
22import androidx.compose.runtime.remember
19import androidx.compose.runtime.setValue
23import androidx.compose.runtime.setValue
20import androidx.compose.ui.Modifier
24import androidx.compose.ui.Modifier
25+import androidx.compose.ui.platform.LocalContext
26+import androidx.compose.ui.res.stringResource
21import androidx.compose.ui.tooling.preview.Preview
27import androidx.compose.ui.tooling.preview.Preview
22import androidx.compose.ui.unit.dp
28import androidx.compose.ui.unit.dp
29+import kotlinx.coroutines.Dispatchers
30+import kotlinx.coroutines.withContext
31+import soy.iko.opencode.R
23import soy.iko.opencode.ui.theme.Dimens
32import soy.iko.opencode.ui.theme.Dimens
24import soy.iko.opencode.ui.theme.OpencodeTheme
33import soy.iko.opencode.ui.theme.OpencodeTheme
25import java.io.File
34import java.io.File
@@ -30,9 +39,8 @@ import java.io.File
30 * capped at 60% of the viewport via [Modifier.heightIn] so very long traces
39 * capped at 60% of the viewport via [Modifier.heightIn] so very long traces
31 * scroll internally without consuming the whole screen.
40 * scroll internally without consuming the whole screen.
32 *
41 *
33− * Crash report file contents are read once via [remember] (off the first
42+ * Crash report file contents are read asynchronously via [LaunchedEffect] on
34− * recomposition) rather than on every recomposition — [File.readText] is disk
43+ * [Dispatchers.IO] so disk I/O never blocks the composition thread.
35− * I/O and shouldn't run in composition repeatedly.
36 *
44 *
37 * "Clear all" is a destructive action guarded by a confirmation step. The
45 * "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
46 * single dialog swaps its content and buttons between the list view and the
@@ -40,6 +48,8 @@ import java.io.File
40 *
48 *
41 * - List view: dismiss = "Clear all" (error-tinted), confirm = "Close"
49 * - List view: dismiss = "Clear all" (error-tinted), confirm = "Close"
42 * - Confirm view: dismiss = "Cancel", confirm = "Clear" (error-tinted)
50 * - Confirm view: dismiss = "Cancel", confirm = "Clear" (error-tinted)
51+ *
52+ * Each report has a "Copy" and "Share" button for filing bug reports.
43 */
53 */
44@Composable
54@Composable
45fun CrashLogDialog(
55fun CrashLogDialog(
@@ -53,12 +63,21 @@ fun CrashLogDialog(
53 cancelLabel: String = "Cancel",
63 cancelLabel: String = "Cancel",
54 clearConfirmLabel: String = "Clear all crash reports?",
64 clearConfirmLabel: String = "Clear all crash reports?",
55 clearConfirmYesLabel: String = "Clear",
65 clearConfirmYesLabel: String = "Clear",
66+ copyLabel: String = "Copy",
67+ shareLabel: String = "Share",
68+ copiedLabel: String = "Crash report copied",
69+ emptyLabel: String = "No crash reports",
56) {
70) {
57− // Read each report's text once, not on every recomposition.
71+ // Read crash report contents asynchronously on Dispatchers.IO so disk
58− val contents = remember(reports) {
72+ // I/O never blocks the composition thread.
73+ var contents by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
74+ LaunchedEffect(reports) {
75+ contents = withContext(Dispatchers.IO) {
59− reports.map { it.name to it.readText() }
76+ reports.map { it.name to it.readText() }
77+ }
60 }
78 }
61 var confirmingClear by remember { mutableStateOf(false) }
79 var confirmingClear by remember { mutableStateOf(false) }
80+ val context = LocalContext.current
62
81
63 AlertDialog(
82 AlertDialog(
64 onDismissRequest = {
83 onDismissRequest = {
@@ -72,6 +91,12 @@ fun CrashLogDialog(
72 text = clearConfirmLabel,
91 text = clearConfirmLabel,
73 style = MaterialTheme.typography.bodyMedium,
92 style = MaterialTheme.typography.bodyMedium,
74 )
93 )
94+ } else if (contents.isEmpty()) {
95+ Text(
96+ text = emptyLabel,
97+ style = MaterialTheme.typography.bodyMedium,
98+ color = MaterialTheme.colorScheme.onSurfaceVariant,
99+ )
75 } else {
100 } else {
76 Column(
101 Column(
77 modifier = Modifier
102 modifier = Modifier
@@ -80,11 +105,47 @@ fun CrashLogDialog(
80 .verticalScroll(rememberScrollState()),
105 .verticalScroll(rememberScrollState()),
81 ) {
106 ) {
82 contents.forEachIndexed { index, (name, text) ->
107 contents.forEachIndexed { index, (name, text) ->
83− Text(
108+ Row(
109+ modifier = Modifier.fillMaxWidth(),
110+ horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
111+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
112+ ) {
113+ Text(
84− text = name,
114+ text = name,
85− style = MaterialTheme.typography.labelMedium,
115+ style = MaterialTheme.typography.labelMedium,
86− color = MaterialTheme.colorScheme.primary,
116+ color = MaterialTheme.colorScheme.primary,
87− )
117+ modifier = Modifier.weight(1f),
118+ )
119+ Spacer(Modifier.size(Dimens.spaceSmall))
120+ TextButton(
121+ onClick = {
122+ val clipboard = context.getSystemService(android.content.ClipboardManager::class.java)
123+ clipboard?.setPrimaryClip(
124+ android.content.ClipData.newPlainText("crash_report", text),
125+ )
126+ },
127+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
128+ horizontal = Dimens.spaceSmall,
129+ vertical = 0.dp,
130+ ),
131+ ) { Text(copyLabel, style = MaterialTheme.typography.labelSmall) }
132+ TextButton(
133+ onClick = {
134+ val intent = Intent(Intent.ACTION_SEND).apply {
135+ type = "text/plain"
136+ putExtra(Intent.EXTRA_SUBJECT, name)
137+ putExtra(Intent.EXTRA_TEXT, text)
138+ }
139+ context.startActivity(
140+ Intent.createChooser(intent, shareLabel).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
141+ )
142+ },
143+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
144+ horizontal = Dimens.spaceSmall,
145+ vertical = 0.dp,
146+ ),
147+ ) { Text(shareLabel, style = MaterialTheme.typography.labelSmall) }
148+ }
88 Spacer(Modifier.height(Dimens.spaceTiny))
149 Spacer(Modifier.height(Dimens.spaceTiny))
89 Text(
150 Text(
90 text = text,
151 text = text,
app/src/main/java/soy/iko/opencode/ui/components/DateSeparator.kt+23−5
@@ -11,11 +11,13 @@ import androidx.compose.runtime.remember
11import androidx.compose.ui.Alignment
11import androidx.compose.ui.Alignment
12import androidx.compose.ui.Modifier
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.platform.LocalContext
13import androidx.compose.ui.platform.LocalContext
14+import androidx.compose.ui.res.stringResource
14import androidx.compose.ui.semantics.contentDescription
15import androidx.compose.ui.semantics.contentDescription
15import androidx.compose.ui.semantics.semantics
16import androidx.compose.ui.semantics.semantics
16import androidx.compose.ui.text.style.TextAlign
17import androidx.compose.ui.text.style.TextAlign
17import androidx.compose.ui.tooling.preview.Preview
18import androidx.compose.ui.tooling.preview.Preview
18import androidx.compose.ui.unit.dp
19import androidx.compose.ui.unit.dp
20+import soy.iko.opencode.R
19import soy.iko.opencode.ui.theme.OpencodeTheme
21import soy.iko.opencode.ui.theme.OpencodeTheme
20import java.util.Calendar
22import java.util.Calendar
21import java.util.Date
23import java.util.Date
@@ -29,7 +31,9 @@ import java.util.Locale
29@Composable
31@Composable
30fun DateSeparator(epochSeconds: ULong, modifier: Modifier = Modifier) {
32fun DateSeparator(epochSeconds: ULong, modifier: Modifier = Modifier) {
31 val context = LocalContext.current
33 val context = LocalContext.current
34+ val todayLabel = stringResource(R.string.date_today)
35+ val yesterdayLabel = stringResource(R.string.date_yesterday)
32− val label = remember(epochSeconds) { formatDateLabel(epochSeconds, context) }
36+ val label = remember(epochSeconds) { formatDateLabel(epochSeconds, context, todayLabel, yesterdayLabel) }
33 Box(
37 Box(
34 modifier = modifier
38 modifier = modifier
35 .fillMaxWidth()
39 .fillMaxWidth()
@@ -46,10 +50,24 @@ fun DateSeparator(epochSeconds: ULong, modifier: Modifier = Modifier) {
46 }
50 }
47}
51}
48
52
53+private fun formatDateLabel(
54+ epochSeconds: ULong,
49−private fun formatDateLabel(epochSeconds: ULong, context: android.content.Context): String {
55+ context: android.content.Context,
56+ todayLabel: String,
57+ yesterdayLabel: String,
58+): String {
50 val date = Date(epochSeconds.toLong() * 1000)
59 val date = Date(epochSeconds.toLong() * 1000)
60+ val today = dayOfEpoch((Calendar.getInstance(Locale.getDefault()).timeInMillis / 1000).toULong())
61+ val yesterday = today - 1
62+ val target = dayOfEpoch(epochSeconds)
63+ return when (target) {
64+ today -> todayLabel
65+ yesterday -> yesterdayLabel
66+ else -> {
51− val formatter = DateFormat.getLongDateFormat(context)
67+ val formatter = DateFormat.getLongDateFormat(context)
52− return formatter.format(date)
68+ formatter.format(date)
69+ }
70+ }
53}
71}
54
72
55/**
73/**
@@ -57,7 +75,7 @@ private fun formatDateLabel(epochSeconds: ULong, context: android.content.Contex
57 * in the device's default timezone. Returns 0 for time == 0 (unknown).
75 * in the device's default timezone. Returns 0 for time == 0 (unknown).
58 */
76 */
59internal fun dayOfEpoch(epochSeconds: ULong): Int {
77internal fun dayOfEpoch(epochSeconds: ULong): Int {
60− if (epochSeconds == 0uL) return 0
78+ if (epochSeconds == 0uL) return Int.MIN_VALUE
61 val cal = Calendar.getInstance(Locale.getDefault())
79 val cal = Calendar.getInstance(Locale.getDefault())
62 cal.timeInMillis = epochSeconds.toLong() * 1000
80 cal.timeInMillis = epochSeconds.toLong() * 1000
63 return cal.get(Calendar.YEAR) * 366 + cal.get(Calendar.DAY_OF_YEAR)
81 return cal.get(Calendar.YEAR) * 366 + cal.get(Calendar.DAY_OF_YEAR)
app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.kt+2
@@ -73,6 +73,7 @@ fun InfoHost(
73 successConnectedLabel: String,
73 successConnectedLabel: String,
74 successSessionCreatedLabel: String,
74 successSessionCreatedLabel: String,
75 copiedLabel: String = "Copied",
75 copiedLabel: String = "Copied",
76+ sessionDeletedLabel: String = "Session deleted",
76): SnackbarHostState {
77): SnackbarHostState {
77 val snackbarHostState = remember { SnackbarHostState() }
78 val snackbarHostState = remember { SnackbarHostState() }
78 LaunchedEffect(snackbarHostState) {
79 LaunchedEffect(snackbarHostState) {
@@ -81,6 +82,7 @@ fun InfoHost(
81 Core.SUCCESS_CONNECTED -> successConnectedLabel
82 Core.SUCCESS_CONNECTED -> successConnectedLabel
82 Core.SUCCESS_SESSION_CREATED -> successSessionCreatedLabel
83 Core.SUCCESS_SESSION_CREATED -> successSessionCreatedLabel
83 Core.SUCCESS_COPIED -> copiedLabel
84 Core.SUCCESS_COPIED -> copiedLabel
85+ Core.SUCCESS_SESSION_DELETED -> sessionDeletedLabel
84 else -> key
86 else -> key
85 }
87 }
86 snackbarHostState.showSnackbar(
88 snackbarHostState.showSnackbar(
app/src/main/java/soy/iko/opencode/ui/components/LoadingPlaceholder.kt+3−1
@@ -11,12 +11,14 @@ import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
11import androidx.compose.runtime.Composable
12import androidx.compose.ui.Alignment
12import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
13import androidx.compose.ui.Modifier
14+import androidx.compose.ui.res.stringResource
14import androidx.compose.ui.semantics.LiveRegionMode
15import androidx.compose.ui.semantics.LiveRegionMode
15import androidx.compose.ui.semantics.contentDescription
16import androidx.compose.ui.semantics.contentDescription
16import androidx.compose.ui.semantics.liveRegion
17import androidx.compose.ui.semantics.liveRegion
17import androidx.compose.ui.semantics.semantics
18import androidx.compose.ui.semantics.semantics
18import androidx.compose.ui.text.style.TextAlign
19import androidx.compose.ui.text.style.TextAlign
19import androidx.compose.ui.tooling.preview.Preview
20import androidx.compose.ui.tooling.preview.Preview
21+import soy.iko.opencode.R
20import soy.iko.opencode.ui.theme.Dimens
22import soy.iko.opencode.ui.theme.Dimens
21import soy.iko.opencode.ui.theme.OpencodeTheme
23import soy.iko.opencode.ui.theme.OpencodeTheme
22
24
@@ -28,7 +30,7 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
28@Composable
30@Composable
29fun LoadingPlaceholder(
31fun LoadingPlaceholder(
30 modifier: Modifier = Modifier,
32 modifier: Modifier = Modifier,
31− contentDescription: String = "Loading",
33+ contentDescription: String = stringResource(R.string.loading),
32) {
34) {
33 Box(
35 Box(
34 modifier = modifier
36 modifier = modifier
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+36−8
@@ -17,12 +17,17 @@ import androidx.compose.foundation.layout.height
17import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.layout.widthIn
19import androidx.compose.foundation.layout.widthIn
20+import androidx.compose.material.icons.Icons
21+import androidx.compose.material.icons.filled.ErrorOutline
22+import androidx.compose.material3.CircularProgressIndicator
23+import androidx.compose.material3.Icon
20import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.Surface
25import androidx.compose.material3.Surface
22import androidx.compose.material3.Text
26import androidx.compose.material3.Text
23import androidx.compose.runtime.Composable
27import androidx.compose.runtime.Composable
24import androidx.compose.runtime.getValue
28import androidx.compose.runtime.getValue
25import androidx.compose.runtime.remember
29import androidx.compose.runtime.remember
30+import androidx.compose.ui.Alignment
26import androidx.compose.ui.Modifier
31import androidx.compose.ui.Modifier
27import androidx.compose.ui.hapticfeedback.HapticFeedbackType
32import androidx.compose.ui.hapticfeedback.HapticFeedbackType
28import androidx.compose.ui.platform.LocalClipboardManager
33import androidx.compose.ui.platform.LocalClipboardManager
@@ -41,6 +46,7 @@ import androidx.compose.ui.tooling.preview.Preview
41import androidx.compose.ui.unit.dp
46import androidx.compose.ui.unit.dp
42import com.mikepenz.markdown.m3.Markdown
47import com.mikepenz.markdown.m3.Markdown
43import soy.iko.opencode.R
48import soy.iko.opencode.R
49+import soy.iko.opencode.core.MessageStatus
44import soy.iko.opencode.core.MessageView
50import soy.iko.opencode.core.MessageView
45import soy.iko.opencode.ui.theme.AssistantBubbleShape
51import soy.iko.opencode.ui.theme.AssistantBubbleShape
46import soy.iko.opencode.ui.theme.Dimens
52import soy.iko.opencode.ui.theme.Dimens
@@ -152,18 +158,38 @@ fun MessageBubble(
152 }
158 }
153 Spacer(Modifier.height(Dimens.spaceTiny))
159 Spacer(Modifier.height(Dimens.spaceTiny))
154 } else {
160 } else {
155− // Show a timestamp on user bubbles too, aligned to the
161+ // Show a timestamp and status indicator on user bubbles,
156− // end so it reads as the message's send time.
162+ // aligned to the end so it reads as the message's send time.
163+ val showStatus = message.status != MessageStatus.SENT
157− if (message.time > 0uL) {
164+ if (message.time > 0uL || showStatus) {
158 Row(
165 Row(
159 modifier = Modifier.fillMaxWidth(),
166 modifier = Modifier.fillMaxWidth(),
160 horizontalArrangement = Arrangement.End,
167 horizontalArrangement = Arrangement.End,
168+ verticalAlignment = Alignment.CenterVertically,
161 ) {
169 ) {
170+ if (message.status == MessageStatus.PENDING) {
171+ CircularProgressIndicator(
172+ modifier = Modifier.size(Dimens.iconInlineSpinner),
173+ strokeWidth = Dimens.strokeThin,
174+ color = MaterialTheme.colorScheme.onPrimary,
175+ )
176+ Spacer(Modifier.size(Dimens.spaceTiny))
177+ } else if (message.status == MessageStatus.FAILED) {
178+ Icon(
179+ Icons.Default.ErrorOutline,
180+ contentDescription = stringResource(R.string.chat_cd_message_status_failed),
181+ modifier = Modifier.size(Dimens.iconInlineSpinner),
182+ tint = MaterialTheme.colorScheme.onPrimary,
183+ )
184+ Spacer(Modifier.size(Dimens.spaceTiny))
185+ }
186+ if (message.time > 0uL) {
162− Text(
187+ Text(
163− text = timeLabel,
188+ text = timeLabel,
164− style = MaterialTheme.typography.labelSmall,
189+ style = MaterialTheme.typography.labelSmall,
165− color = MaterialTheme.colorScheme.onPrimary,
190+ color = MaterialTheme.colorScheme.onPrimary,
166− )
191+ )
192+ }
167 }
193 }
168 Spacer(Modifier.height(Dimens.spaceTiny))
194 Spacer(Modifier.height(Dimens.spaceTiny))
169 }
195 }
@@ -328,6 +354,7 @@ private fun MessageBubbleUserPreview() {
328 role = "user",
354 role = "user",
329 text = "Hello! Can you summarize the build steps?",
355 text = "Hello! Can you summarize the build steps?",
330 time = 1_700_000_000uL,
356 time = 1_700_000_000uL,
357+ status = MessageStatus.SENT,
331 ),
358 ),
332 )
359 )
333 MessageBubble(
360 MessageBubble(
@@ -336,6 +363,7 @@ private fun MessageBubbleUserPreview() {
336 role = "assistant",
363 role = "assistant",
337 text = "Sure — here's a **short** list:\n\n1. Generate types\n2. Build the native library\n3. Assemble the APK",
364 text = "Sure — here's a **short** list:\n\n1. Generate types\n2. Build the native library\n3. Assemble the APK",
338 time = 1_700_000_010uL,
365 time = 1_700_000_010uL,
366+ status = MessageStatus.SENT,
339 ),
367 ),
340 )
368 )
341 }
369 }
app/src/main/java/soy/iko/opencode/ui/components/SseDisconnectedBanner.kt+39
@@ -93,3 +93,42 @@ private fun SseDisconnectedBannerPreview() {
93 )
93 )
94 }
94 }
95}
95}
96+
97+/**
98+ * A thin non-actionable status banner (e.g. "Connecting…") shown above the
99+ * message list. Uses a neutral color scheme so it doesn't read as an error.
100+ */
101+@Composable
102+fun SseStatusBanner(
103+ message: String,
104+ containerColor: androidx.compose.ui.graphics.Color,
105+ contentColor: androidx.compose.ui.graphics.Color,
106+ modifier: Modifier = Modifier,
107+) {
108+ Surface(
109+ color = containerColor,
110+ contentColor = contentColor,
111+ modifier = modifier
112+ .fillMaxWidth()
113+ .semantics(mergeDescendants = true) {
114+ liveRegion = LiveRegionMode.Polite
115+ contentDescription = message
116+ },
117+ ) {
118+ Row(
119+ verticalAlignment = Alignment.CenterVertically,
120+ horizontalArrangement = Arrangement.Center,
121+ modifier = Modifier
122+ .fillMaxWidth()
123+ .padding(
124+ horizontal = Dimens.spaceLarge,
125+ vertical = Dimens.spaceSmall,
126+ ),
127+ ) {
128+ Text(
129+ text = message,
130+ style = MaterialTheme.typography.bodySmall,
131+ )
132+ }
133+ }
134+}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+82−23
@@ -3,17 +3,23 @@ package soy.iko.opencode.ui.screens
3import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.scaleIn
4import androidx.compose.animation.scaleIn
5import androidx.compose.animation.scaleOut
5import androidx.compose.animation.scaleOut
6+import androidx.compose.foundation.ExperimentalFoundationApi
7+import androidx.compose.foundation.combinedClickable
6import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.PaddingValues
11import androidx.compose.foundation.layout.PaddingValues
12+import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxSize
14+import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.padding
16+import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.items
18import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.lazy.rememberLazyListState
19import androidx.compose.foundation.lazy.rememberLazyListState
15import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
22+import androidx.compose.material.icons.filled.ContentCopy
17import androidx.compose.material.icons.filled.KeyboardArrowDown
23import androidx.compose.material.icons.filled.KeyboardArrowDown
18import androidx.compose.material.icons.filled.MoreVert
24import androidx.compose.material.icons.filled.MoreVert
19import androidx.compose.material.icons.filled.Refresh
25import androidx.compose.material.icons.filled.Refresh
@@ -35,18 +41,24 @@ import androidx.compose.runtime.getValue
35import androidx.compose.runtime.mutableIntStateOf
41import androidx.compose.runtime.mutableIntStateOf
36import androidx.compose.runtime.mutableStateOf
42import androidx.compose.runtime.mutableStateOf
37import androidx.compose.runtime.remember
43import androidx.compose.runtime.remember
44+import androidx.compose.runtime.saveable.rememberSaveable
38import androidx.compose.runtime.setValue
45import androidx.compose.runtime.setValue
39import androidx.compose.ui.Alignment
46import androidx.compose.ui.Alignment
40import androidx.compose.ui.Modifier
47import androidx.compose.ui.Modifier
41import androidx.compose.ui.focus.FocusRequester
48import androidx.compose.ui.focus.FocusRequester
42import androidx.compose.ui.focus.focusRequester
49import androidx.compose.ui.focus.focusRequester
50+import androidx.compose.ui.platform.LocalClipboardManager
43import androidx.compose.ui.res.stringResource
51import androidx.compose.ui.res.stringResource
52+import androidx.compose.ui.semantics.contentDescription
53+import androidx.compose.ui.semantics.semantics
54+import androidx.compose.ui.text.AnnotatedString
44import androidx.compose.ui.text.style.TextOverflow
55import androidx.compose.ui.text.style.TextOverflow
45import androidx.compose.ui.tooling.preview.Preview
56import androidx.compose.ui.tooling.preview.Preview
46import androidx.compose.ui.unit.dp
57import androidx.compose.ui.unit.dp
47import soy.iko.opencode.R
58import soy.iko.opencode.R
48import soy.iko.opencode.core.Core
59import soy.iko.opencode.core.Core
49import soy.iko.opencode.core.Event
60import soy.iko.opencode.core.Event
61+import soy.iko.opencode.core.MessageStatus
50import soy.iko.opencode.core.MessageView
62import soy.iko.opencode.core.MessageView
51import soy.iko.opencode.core.SseState
63import soy.iko.opencode.core.SseState
52import soy.iko.opencode.ui.components.ChatInputBar
64import soy.iko.opencode.ui.components.ChatInputBar
@@ -59,29 +71,36 @@ import soy.iko.opencode.ui.components.InfoHost
59import soy.iko.opencode.ui.components.LoadingPlaceholder
71import soy.iko.opencode.ui.components.LoadingPlaceholder
60import soy.iko.opencode.ui.components.MessageBubble
72import soy.iko.opencode.ui.components.MessageBubble
61import soy.iko.opencode.ui.components.SseDisconnectedBanner
73import soy.iko.opencode.ui.components.SseDisconnectedBanner
74+import soy.iko.opencode.ui.components.SseStatusBanner
62import soy.iko.opencode.ui.components.dayOfEpoch
75import soy.iko.opencode.ui.components.dayOfEpoch
63import soy.iko.opencode.ui.theme.Dimens
76import soy.iko.opencode.ui.theme.Dimens
64import soy.iko.opencode.ui.theme.OpencodeTheme
77import soy.iko.opencode.ui.theme.OpencodeTheme
65
78
66−@OptIn(ExperimentalMaterial3Api::class)
79+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
67@Composable
80@Composable
68fun ChatScreen(core: Core) {
81fun ChatScreen(core: Core) {
69 val view by core.view.collectAsState()
82 val view by core.view.collectAsState()
70 val sseState by core.sseState.collectAsState()
83 val sseState by core.sseState.collectAsState()
84+ // Wire the draft to the core's ViewModel so it survives rotation and
85+ // process death. The core stores draftMessage; we seed local state from
86+ // it and write via Event.DraftChanged on every keystroke.
71− var inputText by remember { mutableStateOf("") }
87+ var inputText by remember(view.draftMessage) { mutableStateOf(view.draftMessage) }
72 val listState = rememberLazyListState()
88 val listState = rememberLazyListState()
73− var scrollRequest by remember { mutableIntStateOf(0) }
89+ var scrollRequest by rememberSaveable { mutableIntStateOf(0) }
74− var menuExpanded by remember { mutableStateOf(false) }
90+ var menuExpanded by rememberSaveable { mutableStateOf(false) }
75− var sseBannerDismissed by remember { mutableStateOf(false) }
91+ var sseBannerDismissed by rememberSaveable { mutableStateOf(false) }
76 val inputFocusRequester = remember { FocusRequester() }
92 val inputFocusRequester = remember { FocusRequester() }
93+ val clipboardManager = LocalClipboardManager.current
77
94
78 // Track whether the user is parked at the bottom of the list. We only
95 // Track whether the user is parked at the bottom of the list. We only
79 // auto-scroll when they are, so reading history isn't yanked away as
96 // auto-scroll when they are, so reading history isn't yanked away as
80 // new messages stream in.
97 // new messages stream in.
81 val isAtBottom by remember {
98 val isAtBottom by remember {
82 derivedStateOf {
99 derivedStateOf {
100+ val layoutInfo = listState.layoutInfo
101+ val totalItems = layoutInfo.totalItemsCount
83− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
102+ val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
84− lastVisible >= view.messages.size - 2
103+ totalItems == 0 || lastVisible >= totalItems - 2
85 }
104 }
86 }
105 }
87
106
@@ -95,10 +114,7 @@ fun ChatScreen(core: Core) {
95 // repeated taps keep firing.
114 // repeated taps keep firing.
96 LaunchedEffect(scrollRequest) {
115 LaunchedEffect(scrollRequest) {
97 if (scrollRequest > 0 && view.messages.isNotEmpty()) {
116 if (scrollRequest > 0 && view.messages.isNotEmpty()) {
98− // Index of the trailing __generating__ item (it's last in the
99− // list, at position == messages.size). Falling back to the last
100− // message is safe even if the generating item is invisible.
101− listState.animateScrollToItem(view.messages.size)
117+ listState.animateScrollToItem(listState.layoutInfo.totalItemsCount - 1)
102 }
118 }
103 }
119 }
104
120
@@ -122,6 +138,7 @@ fun ChatScreen(core: Core) {
122 successConnectedLabel = stringResource(R.string.connect_success),
138 successConnectedLabel = stringResource(R.string.connect_success),
123 successSessionCreatedLabel = stringResource(R.string.session_created),
139 successSessionCreatedLabel = stringResource(R.string.session_created),
124 copiedLabel = stringResource(R.string.chat_copied),
140 copiedLabel = stringResource(R.string.chat_copied),
141+ sessionDeletedLabel = stringResource(R.string.session_deleted),
125 )
142 )
126
143
127 val showSseBanner = (sseState is SseState.Error ||
144 val showSseBanner = (sseState is SseState.Error ||
@@ -158,24 +175,53 @@ fun ChatScreen(core: Core) {
158 val refreshLabel = stringResource(R.string.chat_menu_refresh)
175 val refreshLabel = stringResource(R.string.chat_menu_refresh)
159 val overflowLabel = stringResource(R.string.chat_menu_overflow)
176 val overflowLabel = stringResource(R.string.chat_menu_overflow)
160 val chatEmptyHint = stringResource(R.string.chat_empty_hint)
177 val chatEmptyHint = stringResource(R.string.chat_empty_hint)
178+ val copySessionIdCd = stringResource(R.string.chat_cd_copy_session_id)
179+ val sessionIdCopiedLabel = stringResource(R.string.chat_session_id_copied)
161
180
162 Scaffold(
181 Scaffold(
163 topBar = {
182 topBar = {
164 TopAppBar(
183 TopAppBar(
165 title = {
184 title = {
166− Column {
185+ Column(
186+ modifier = Modifier.fillMaxWidth(),
187+ ) {
167 Text(
188 Text(
168 text = title,
189 text = title,
169 maxLines = 1,
190 maxLines = 1,
170 overflow = TextOverflow.Ellipsis,
191 overflow = TextOverflow.Ellipsis,
171 )
192 )
172− if (subtitle != null) {
193+ if (subtitle != null && sessionId != null) {
194+ Row(
195+ verticalAlignment = Alignment.CenterVertically,
196+ modifier = Modifier
197+ .fillMaxWidth()
198+ .combinedClickable(
199+ onClick = {
200+ clipboardManager.setText(AnnotatedString(sessionId))
201+ core.notifyCopied()
202+ },
203+ onLongClick = {
204+ clipboardManager.setText(AnnotatedString(sessionId))
205+ core.notifyCopied()
206+ },
207+ )
208+ .semantics {
209+ contentDescription = copySessionIdCd
210+ },
211+ ) {
173− Text(
212+ Text(
174− text = subtitle,
213+ text = subtitle,
175− style = MaterialTheme.typography.labelSmall,
214+ style = MaterialTheme.typography.labelSmall,
176− color = MaterialTheme.colorScheme.onSurfaceVariant,
215+ color = MaterialTheme.colorScheme.onSurfaceVariant,
177− maxLines = 1,
216+ maxLines = 1,
178− )
217+ )
218+ Icon(
219+ Icons.Default.ContentCopy,
220+ contentDescription = null,
221+ modifier = Modifier.size(Dimens.iconInlineSpinner),
222+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
223+ )
224+ }
179 }
225 }
180 }
226 }
181 },
227 },
@@ -220,18 +266,22 @@ fun ChatScreen(core: Core) {
220 bottomBar = {
266 bottomBar = {
221 ChatInputBar(
267 ChatInputBar(
222 text = inputText,
268 text = inputText,
223− onTextChange = { inputText = it },
269+ onTextChange = {
270+ inputText = it
271+ core.update(Event.DraftChanged(it))
272+ },
224 onSend = {
273 onSend = {
225 if (inputText.isNotBlank()) {
274 if (inputText.isNotBlank()) {
226 core.update(Event.SendMessage(inputText))
275 core.update(Event.SendMessage(inputText))
227 inputText = ""
276 inputText = ""
277+ core.update(Event.DraftChanged(""))
228 }
278 }
229 },
279 },
230 // Disable while loading OR while the assistant is generating,
280 // Disable while loading OR while the assistant is generating,
231 // so users can't fire a second message mid-reply.
281 // so users can't fire a second message mid-reply.
232 enabled = !view.loading && !view.generating,
282 enabled = !view.loading && !view.generating,
233 generating = view.generating,
283 generating = view.generating,
234− onStop = { sessionId?.let { core.update(Event.LoadMessages(it)) } },
284+ onStop = { core.update(Event.CancelGeneration) },
235 stopContentDescription = stringResource(R.string.chat_cd_stop),
285 stopContentDescription = stringResource(R.string.chat_cd_stop),
236 placeholder = stringResource(R.string.chat_input_placeholder),
286 placeholder = stringResource(R.string.chat_input_placeholder),
237 sendContentDescription = stringResource(R.string.chat_cd_send),
287 sendContentDescription = stringResource(R.string.chat_cd_send),
@@ -259,6 +309,14 @@ fun ChatScreen(core: Core) {
259 reconnectLabel = stringResource(R.string.sse_reconnect),
309 reconnectLabel = stringResource(R.string.sse_reconnect),
260 dismissLabel = stringResource(R.string.sse_dismiss),
310 dismissLabel = stringResource(R.string.sse_dismiss),
261 )
311 )
312+ } else if (sseState == SseState.Connecting && sessionId != null && !view.loading) {
313+ // Brief "connecting" indicator while the SSE stream
314+ // establishes — not an error, just a transitional state.
315+ SseStatusBanner(
316+ message = stringResource(R.string.sse_connecting),
317+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
318+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
319+ )
262 }
320 }
263
321
264 when {
322 when {
@@ -366,12 +424,13 @@ private fun ChatScreenPreview() {
366 title = "Refactor the parser",
424 title = "Refactor the parser",
367 sessionId = "abc12345",
425 sessionId = "abc12345",
368 messages = listOf(
426 messages = listOf(
369− MessageView("u1", "user", "Can you clean up parse_messages?", 1_700_000_000uL),
427+ MessageView("u1", "user", "Can you clean up parse_messages?", 1_700_000_000uL, MessageStatus.SENT),
370 MessageView(
428 MessageView(
371 "a1",
429 "a1",
372 "assistant",
430 "assistant",
373 "Sure — here's a **plan**:\n\n1. Extract helpers\n2. Add tests",
431 "Sure — here's a **plan**:\n\n1. Extract helpers\n2. Add tests",
374 1_700_000_010uL,
432 1_700_000_010uL,
433+ MessageStatus.SENT,
375 ),
434 ),
376 ),
435 ),
377 generating = false,
436 generating = false,
@@ -387,7 +446,7 @@ private fun ChatScreenGeneratingPreview() {
387 PreviewChatScreen(
446 PreviewChatScreen(
388 title = "New session",
447 title = "New session",
389 sessionId = "xyz12345",
448 sessionId = "xyz12345",
390− messages = listOf(MessageView("u1", "user", "Hello!", 1_700_000_000uL)),
449+ messages = listOf(MessageView("u1", "user", "Hello!", 1_700_000_000uL, MessageStatus.SENT)),
391 generating = true,
450 generating = true,
392 )
451 )
393 }
452 }
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+4−3
@@ -38,6 +38,7 @@ import androidx.compose.runtime.collectAsState
38import androidx.compose.runtime.getValue
38import androidx.compose.runtime.getValue
39import androidx.compose.runtime.mutableStateOf
39import androidx.compose.runtime.mutableStateOf
40import androidx.compose.runtime.remember
40import androidx.compose.runtime.remember
41+import androidx.compose.runtime.saveable.rememberSaveable
41import androidx.compose.runtime.setValue
42import androidx.compose.runtime.setValue
42import androidx.compose.ui.Alignment
43import androidx.compose.ui.Alignment
43import androidx.compose.ui.Modifier
44import androidx.compose.ui.Modifier
@@ -71,9 +72,9 @@ fun ConnectScreen(core: Core) {
71 var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
72 var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
72 var username by remember(view.username) { mutableStateOf(view.username) }
73 var username by remember(view.username) { mutableStateOf(view.username) }
73 var password by remember(view.password) { mutableStateOf(view.password) }
74 var password by remember(view.password) { mutableStateOf(view.password) }
74− var passwordVisible by remember { mutableStateOf(false) }
75+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
75− var showCrashLogs by remember { mutableStateOf(false) }
76+ var showCrashLogs by rememberSaveable { mutableStateOf(false) }
76− var urlError by remember { mutableStateOf(false) }
77+ var urlError by rememberSaveable { mutableStateOf(false) }
77 val errorHostState = ErrorHost(
78 val errorHostState = ErrorHost(
78 core = core,
79 core = core,
79 dismissLabel = stringResource(R.string.action_dismiss),
80 dismissLabel = stringResource(R.string.action_dismiss),
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+105−11
@@ -1,7 +1,10 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
2
2
3+import androidx.compose.foundation.ExperimentalFoundationApi
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.clickable
5+import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
7+import androidx.compose.foundation.layout.PaddingValues
5import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxSize
6import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
7import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
@@ -11,6 +14,8 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
11import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
14import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
12import androidx.compose.material.icons.filled.Add
15import androidx.compose.material.icons.filled.Add
13import androidx.compose.material.icons.filled.ChatBubble
16import androidx.compose.material.icons.filled.ChatBubble
17+import androidx.compose.material.icons.filled.Delete
18+import androidx.compose.material3.AlertDialog
14import androidx.compose.material3.ExperimentalMaterial3Api
19import androidx.compose.material3.ExperimentalMaterial3Api
15import androidx.compose.material3.Icon
20import androidx.compose.material3.Icon
16import androidx.compose.material3.IconButton
21import androidx.compose.material3.IconButton
@@ -18,17 +23,22 @@ import androidx.compose.material3.ListItem
18import androidx.compose.material3.MaterialTheme
23import androidx.compose.material3.MaterialTheme
19import androidx.compose.material3.Scaffold
24import androidx.compose.material3.Scaffold
20import androidx.compose.material3.Text
25import androidx.compose.material3.Text
26+import androidx.compose.material3.TextButton
21import androidx.compose.material3.TopAppBar
27import androidx.compose.material3.TopAppBar
22import androidx.compose.material3.pulltorefresh.PullToRefreshBox
28import androidx.compose.material3.pulltorefresh.PullToRefreshBox
23import androidx.compose.runtime.Composable
29import androidx.compose.runtime.Composable
24import androidx.compose.runtime.collectAsState
30import androidx.compose.runtime.collectAsState
25import androidx.compose.runtime.getValue
31import androidx.compose.runtime.getValue
32+import androidx.compose.runtime.mutableStateOf
33+import androidx.compose.runtime.remember
34+import androidx.compose.runtime.setValue
26import androidx.compose.ui.Modifier
35import androidx.compose.ui.Modifier
27import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.res.stringResource
28import androidx.compose.ui.semantics.Role
37import androidx.compose.ui.semantics.Role
29import androidx.compose.ui.semantics.contentDescription
38import androidx.compose.ui.semantics.contentDescription
30import androidx.compose.ui.semantics.role
39import androidx.compose.ui.semantics.role
31import androidx.compose.ui.semantics.semantics
40import androidx.compose.ui.semantics.semantics
41+import androidx.compose.ui.text.style.TextOverflow
32import androidx.compose.ui.tooling.preview.Preview
42import androidx.compose.ui.tooling.preview.Preview
33import androidx.compose.ui.unit.dp
43import androidx.compose.ui.unit.dp
34import soy.iko.opencode.R
44import soy.iko.opencode.R
@@ -56,16 +66,26 @@ fun SessionsScreen(core: Core) {
56 core = core,
66 core = core,
57 successConnectedLabel = stringResource(R.string.connect_success),
67 successConnectedLabel = stringResource(R.string.connect_success),
58 successSessionCreatedLabel = stringResource(R.string.session_created),
68 successSessionCreatedLabel = stringResource(R.string.session_created),
69+ copiedLabel = stringResource(R.string.chat_copied),
70+ sessionDeletedLabel = stringResource(R.string.session_deleted),
59 )
71 )
60 val untitledLabel = stringResource(R.string.sessions_untitled)
72 val untitledLabel = stringResource(R.string.sessions_untitled)
61 val idShortTemplate = stringResource(R.string.sessions_id_short)
73 val idShortTemplate = stringResource(R.string.sessions_id_short)
74+ val deleteCdLabel = stringResource(R.string.sessions_cd_delete)
75+ val disconnectConfirmLabel = stringResource(R.string.sessions_disconnect_confirm)
76+ val disconnectYesLabel = stringResource(R.string.sessions_disconnect_confirm_yes)
77+
78+ // Track the session pending deletion (for confirmation dialog) and
79+ // whether the disconnect-from-server confirmation should show.
80+ var pendingDelete by remember { mutableStateOf<SessionView?>(null) }
81+ var showDisconnectConfirm by remember { mutableStateOf(false) }
62
82
63 Scaffold(
83 Scaffold(
64 topBar = {
84 topBar = {
65 TopAppBar(
85 TopAppBar(
66 title = { Text(stringResource(R.string.top_bar_sessions)) },
86 title = { Text(stringResource(R.string.top_bar_sessions)) },
67 navigationIcon = {
87 navigationIcon = {
68− IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
88+ IconButton(onClick = { showDisconnectConfirm = true }) {
69 Icon(
89 Icon(
70 Icons.AutoMirrored.Filled.ArrowBack,
90 Icons.AutoMirrored.Filled.ArrowBack,
71 contentDescription = stringResource(R.string.sessions_cd_back),
91 contentDescription = stringResource(R.string.sessions_cd_back),
@@ -102,13 +122,17 @@ fun SessionsScreen(core: Core) {
102 onRefresh = { core.update(Event.LoadSessions) },
122 onRefresh = { core.update(Event.LoadSessions) },
103 modifier = Modifier.fillMaxSize(),
123 modifier = Modifier.fillMaxSize(),
104 ) {
124 ) {
105− LazyColumn {
125+ LazyColumn(
126+ contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
127+ ) {
106 items(view.sessions, key = { it.id }) { session ->
128 items(view.sessions, key = { it.id }) { session ->
107 SessionRow(
129 SessionRow(
108 session = session,
130 session = session,
109 untitledLabel = untitledLabel,
131 untitledLabel = untitledLabel,
110 idShortLabel = idShortTemplate.format(session.id.take(8)),
132 idShortLabel = idShortTemplate.format(session.id.take(8)),
133+ deleteCdLabel = deleteCdLabel,
111 onClick = { core.update(Event.SelectSession(session.id)) },
134 onClick = { core.update(Event.SelectSession(session.id)) },
135+ onDelete = { pendingDelete = session },
112 )
136 )
113 }
137 }
114 }
138 }
@@ -117,19 +141,80 @@ fun SessionsScreen(core: Core) {
117 }
141 }
118 }
142 }
119 }
143 }
144+
145+ // Delete confirmation dialog
146+ pendingDelete?.let { session ->
147+ AlertDialog(
148+ onDismissRequest = { pendingDelete = null },
149+ title = { Text(session.title.ifEmpty { untitledLabel }) },
150+ text = { Text(stringResource(R.string.sessions_delete_confirm)) },
151+ confirmButton = {
152+ TextButton(onClick = {
153+ core.update(Event.DeleteSession(session.id))
154+ pendingDelete = null
155+ }) {
156+ Text(
157+ text = stringResource(R.string.sessions_delete_confirm_yes),
158+ color = MaterialTheme.colorScheme.error,
159+ )
160+ }
161+ },
162+ dismissButton = {
163+ TextButton(onClick = { pendingDelete = null }) {
164+ Text(stringResource(R.string.crash_reports_cancel))
165+ }
166+ },
167+ )
168+ }
169+
170+ // Disconnect confirmation dialog — going back to Connect is destructive
171+ // (drops the session list and requires a full reconnect), so confirm.
172+ if (showDisconnectConfirm) {
173+ AlertDialog(
174+ onDismissRequest = { showDisconnectConfirm = false },
175+ text = { Text(disconnectConfirmLabel) },
176+ confirmButton = {
177+ TextButton(onClick = {
178+ core.update(Event.NavigateToConnect)
179+ showDisconnectConfirm = false
180+ }) {
181+ Text(disconnectYesLabel)
182+ }
183+ },
184+ dismissButton = {
185+ TextButton(onClick = { showDisconnectConfirm = false }) {
186+ Text(stringResource(R.string.crash_reports_cancel))
187+ }
188+ },
189+ )
190+ }
120}
191}
121
192
193+@OptIn(ExperimentalFoundationApi::class)
122@Composable
194@Composable
123private fun SessionRow(
195private fun SessionRow(
124 session: SessionView,
196 session: SessionView,
125 untitledLabel: String,
197 untitledLabel: String,
126 idShortLabel: String,
198 idShortLabel: String,
199+ deleteCdLabel: String,
127 onClick: () -> Unit,
200 onClick: () -> Unit,
201+ onDelete: () -> Unit,
128) {
202) {
129 val title = session.title.ifEmpty { untitledLabel }
203 val title = session.title.ifEmpty { untitledLabel }
130 ListItem(
204 ListItem(
131− headlineContent = { Text(title) },
205+ headlineContent = {
206+ Text(
207+ text = title,
208+ maxLines = 1,
209+ overflow = TextOverflow.Ellipsis,
210+ )
211+ },
132− supportingContent = { Text(idShortLabel) },
212+ supportingContent = {
213+ Text(
214+ text = idShortLabel,
215+ maxLines = 1,
216+ )
217+ },
133 leadingContent = {
218 leadingContent = {
134 Icon(
219 Icon(
135 Icons.Default.ChatBubble,
220 Icons.Default.ChatBubble,
@@ -138,14 +223,19 @@ private fun SessionRow(
138 )
223 )
139 },
224 },
140 trailingContent = {
225 trailingContent = {
226+ IconButton(onClick = onDelete) {
141− Icon(
227+ Icon(
142− Icons.AutoMirrored.Filled.KeyboardArrowRight,
228+ Icons.Default.Delete,
143− contentDescription = null,
229+ contentDescription = deleteCdLabel,
144− tint = MaterialTheme.colorScheme.onSurfaceVariant,
230+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
145− )
231+ )
232+ }
146 },
233 },
147 modifier = Modifier
234 modifier = Modifier
235+ .combinedClickable(
148− .clickable(onClick = onClick)
236+ onClick = onClick,
237+ onLongClick = onDelete,
238+ )
149 .semantics(mergeDescendants = true) {
239 .semantics(mergeDescendants = true) {
150 // Merge the row into a single TalkBack utterance and announce
240 // Merge the row into a single TalkBack utterance and announce
151 // it as a button so users know it's actionable.
241 // it as a button so users know it's actionable.
@@ -177,7 +267,9 @@ private fun SessionsScreenPreview() {
177 )
267 )
178 }) { padding ->
268 }) { padding ->
179 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
269 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
180− LazyColumn {
270+ LazyColumn(
271+ contentPadding = PaddingValues(vertical = 12.dp),
272+ ) {
181 items(
273 items(
182 listOf(
274 listOf(
183 SessionView("abc12345def", "Refactor the parser"),
275 SessionView("abc12345def", "Refactor the parser"),
@@ -189,7 +281,9 @@ private fun SessionsScreenPreview() {
189 session,
281 session,
190 untitledLabel = "Untitled",
282 untitledLabel = "Untitled",
191 idShortLabel = "Session ${session.id.take(8)}",
283 idShortLabel = "Session ${session.id.take(8)}",
284+ deleteCdLabel = "Delete session",
192 onClick = {},
285 onClick = {},
286+ onDelete = {},
193 )
287 )
194 }
288 }
195 }
289 }
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+2−2
@@ -19,7 +19,7 @@ private val DarkColorScheme = darkColorScheme(
19 surface = Color(0xFF1E1E1E),
19 surface = Color(0xFF1E1E1E),
20 onPrimary = Color.Black,
20 onPrimary = Color.Black,
21 onSecondary = Color.Black,
21 onSecondary = Color.Black,
22− onTertiary = Color.Black,
22+ onTertiary = Color(0xFF1A1A1A),
23 onBackground = Color.White,
23 onBackground = Color.White,
24 onSurface = Color.White,
24 onSurface = Color.White,
25)
25)
@@ -27,7 +27,7 @@ private val DarkColorScheme = darkColorScheme(
27private val LightColorScheme = lightColorScheme(
27private val LightColorScheme = lightColorScheme(
28 primary = Color(0xFF1565C0),
28 primary = Color(0xFF1565C0),
29 secondary = Color(0xFF00897B),
29 secondary = Color(0xFF00897B),
30− tertiary = Color(0xFFF57C00),
30+ tertiary = Color(0xFFE65100),
31 background = Color(0xFFF5F5F5),
31 background = Color(0xFFF5F5F5),
32 surface = Color.White,
32 surface = Color.White,
33 onPrimary = Color.White,
33 onPrimary = Color.White,
app/src/main/res/values/strings.xml+31
@@ -68,5 +68,36 @@
68 <string name="sse_disconnected">Live stream disconnected</string>
68 <string name="sse_disconnected">Live stream disconnected</string>
69 <string name="sse_reconnect">Reconnect</string>
69 <string name="sse_reconnect">Reconnect</string>
70 <string name="sse_dismiss">Dismiss</string>
70 <string name="sse_dismiss">Dismiss</string>
71+ <string name="sse_connecting">Connecting live stream…</string>
72+ <string name="sse_live">Live</string>
71 <string name="press_back_again_to_exit">Press back again to exit</string>
73 <string name="press_back_again_to_exit">Press back again to exit</string>
74+
75+ <!-- Loading / common -->
76+ <string name="loading">Loading</string>
77+ <string name="date_today">Today</string>
78+ <string name="date_yesterday">Yesterday</string>
79+ <string name="session_deleted">Session deleted</string>
80+
81+ <!-- Sessions screen: delete / disconnect -->
82+ <string name="sessions_cd_delete">Delete session</string>
83+ <string name="sessions_delete_confirm">Delete this session? This cannot be undone.</string>
84+ <string name="sessions_delete_confirm_yes">Delete</string>
85+ <string name="sessions_disconnect_confirm">Disconnect from the server?</string>
86+ <string name="sessions_disconnect_confirm_yes">Disconnect</string>
87+ <string name="sessions_cd_disconnect">Disconnect from server</string>
88+
89+ <!-- Chat: session ID copy / SSE status -->
90+ <string name="chat_cd_copy_session_id">Copy session ID</string>
91+ <string name="chat_session_id_copied">Session ID copied</string>
92+
93+ <!-- Chat: message status -->
94+ <string name="chat_message_status_pending">Sending</string>
95+ <string name="chat_message_status_failed">Failed to send</string>
96+ <string name="chat_cd_message_status_failed">Failed to send. Long-press to retry.</string>
97+
98+ <!-- Crash log dialog -->
99+ <string name="crash_reports_copy">Copy</string>
100+ <string name="crash_reports_share">Share</string>
101+ <string name="crash_reports_copied">Crash report copied</string>
102+ <string name="crash_reports_empty">No crash reports</string>
72</resources>
103</resources>
app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.kt+55
@@ -226,4 +226,59 @@ class PureCoreFfiTest {
226 fun `extractJsonNumber returns null for missing key`() {
226 fun `extractJsonNumber returns null for missing key`() {
227 assertNull(extractJsonNumber("""{"a":"b"}""", "created"))
227 assertNull(extractJsonNumber("""{"a":"b"}""", "created"))
228 }
228 }
229+
230+ @Test
231+ fun `draft changed updates view`() {
232+ val core = createCore()
233+ core.processEvent(Event.Start)
234+ core.processEvent(Event.DraftChanged("in progress"))
235+ val view = core.currentView()
236+ assertEquals("in progress", view.draftMessage)
237+ }
238+
239+ @Test
240+ fun `cancel generation clears generating flag`() {
241+ val core = createCore()
242+ core.processEvent(Event.Start)
243+ // Simulate connected + session selected
244+ val connectReqs = core.processEvent(Event.Connect)
245+ val loadSessionsReqs = core.resolveHttp(connectReqs.value[0].id, body = """{"healthy":true}""")
246+ core.resolveHttp(loadSessionsReqs.value[0].id, body = "[]")
247+ val selectReqs = core.processEvent(Event.SelectSession("session-123"))
248+ core.resolveHttp(selectReqs.value[0].id, body = "[]")
249+
250+ // CancelGeneration is a no-op when not generating, but shouldn't crash
251+ core.processEvent(Event.CancelGeneration)
252+ val view = core.currentView()
253+ assertFalse(view.generating)
254+ }
255+
256+ @Test
257+ fun `delete session removes from list and emits http effect`() {
258+ val core = createCore()
259+ core.processEvent(Event.Start)
260+ val connectReqs = core.processEvent(Event.Connect)
261+ // Health check success → triggers LoadSessions
262+ val loadSessionsReqs = core.resolveHttp(connectReqs.value[0].id, body = """{"healthy":true}""")
263+ // Sessions loaded
264+ val sessionsBody = """[{"id":"s1","title":"First"},{"id":"s2","title":"Second"}]"""
265+ core.resolveHttp(loadSessionsReqs.value[0].id, body = sessionsBody)
266+
267+ val viewBefore = core.currentView()
268+ assertEquals(2, viewBefore.sessions.size)
269+
270+ val deleteReqs = core.processEvent(Event.DeleteSession("s1"))
271+ assertTrue(deleteReqs.value.isNotEmpty())
272+ val viewAfter = core.currentView()
273+ assertEquals(1, viewAfter.sessions.size)
274+ assertEquals("s2", viewAfter.sessions[0].id)
275+ }
276+
277+ @Test
278+ fun `parseMessages sets status to sent`() {
279+ val json = """[{"id":"m1","role":"user","created":100,"parts":[{"type":"text","text":"Hello"}]}]"""
280+ val messages = parseMessages(json)
281+ assertEquals(1, messages.size)
282+ assertEquals(MessageStatus.SENT, messages[0].status)
283+ }
229}
284}
flake.locknew file+61
@@ -0,0 +1,61 @@
1+{
2+ "nodes": {
3+ "flake-utils": {
4+ "inputs": {
5+ "systems": "systems"
6+ },
7+ "locked": {
8+ "lastModified": 1731533236,
9+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10+ "owner": "numtide",
11+ "repo": "flake-utils",
12+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13+ "type": "github"
14+ },
15+ "original": {
16+ "owner": "numtide",
17+ "repo": "flake-utils",
18+ "type": "github"
19+ }
20+ },
21+ "nixpkgs": {
22+ "locked": {
23+ "lastModified": 1783224372,
24+ "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
25+ "owner": "NixOS",
26+ "repo": "nixpkgs",
27+ "rev": "d407951447dcd00442e97087bf374aad70c04cea",
28+ "type": "github"
29+ },
30+ "original": {
31+ "owner": "NixOS",
32+ "ref": "nixos-unstable",
33+ "repo": "nixpkgs",
34+ "type": "github"
35+ }
36+ },
37+ "root": {
38+ "inputs": {
39+ "flake-utils": "flake-utils",
40+ "nixpkgs": "nixpkgs"
41+ }
42+ },
43+ "systems": {
44+ "locked": {
45+ "lastModified": 1681028828,
46+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47+ "owner": "nix-systems",
48+ "repo": "default",
49+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50+ "type": "github"
51+ },
52+ "original": {
53+ "owner": "nix-systems",
54+ "repo": "default",
55+ "type": "github"
56+ }
57+ }
58+ },
59+ "root": "root",
60+ "version": 7
61+}
generated/soy/iko/opencode/core/Core.kt+126−1
@@ -387,6 +387,78 @@ sealed interface Event {
387 }
387 }
388 }
388 }
389
389
390+ data class DraftChanged(
391+ val value: String,
392+ ) : Event {
393+ override fun serialize(serializer: Serializer) {
394+ serializer.increase_container_depth()
395+ serializer.serialize_variant_index(16)
396+ serializer.serialize_str(value)
397+ serializer.decrease_container_depth()
398+ }
399+
400+ companion object {
401+ fun deserialize(deserializer: Deserializer): DraftChanged {
402+ deserializer.increase_container_depth()
403+ val value = deserializer.deserialize_str()
404+ deserializer.decrease_container_depth()
405+ return DraftChanged(value)
406+ }
407+ }
408+ }
409+
410+ data object CancelGeneration: Event {
411+ override fun serialize(serializer: Serializer) {
412+ serializer.increase_container_depth()
413+ serializer.serialize_variant_index(17)
414+ serializer.decrease_container_depth()
415+ }
416+
417+ fun deserialize(deserializer: Deserializer): CancelGeneration {
418+ return CancelGeneration
419+ }
420+ }
421+
422+ data class DeleteSession(
423+ val value: String,
424+ ) : Event {
425+ override fun serialize(serializer: Serializer) {
426+ serializer.increase_container_depth()
427+ serializer.serialize_variant_index(18)
428+ serializer.serialize_str(value)
429+ serializer.decrease_container_depth()
430+ }
431+
432+ companion object {
433+ fun deserialize(deserializer: Deserializer): DeleteSession {
434+ deserializer.increase_container_depth()
435+ val value = deserializer.deserialize_str()
436+ deserializer.decrease_container_depth()
437+ return DeleteSession(value)
438+ }
439+ }
440+ }
441+
442+ data class RetryMessage(
443+ val value: String,
444+ ) : Event {
445+ override fun serialize(serializer: Serializer) {
446+ serializer.increase_container_depth()
447+ serializer.serialize_variant_index(19)
448+ serializer.serialize_str(value)
449+ serializer.decrease_container_depth()
450+ }
451+
452+ companion object {
453+ fun deserialize(deserializer: Deserializer): RetryMessage {
454+ deserializer.increase_container_depth()
455+ val value = deserializer.deserialize_str()
456+ deserializer.decrease_container_depth()
457+ return RetryMessage(value)
458+ }
459+ }
460+ }
461+
390 companion object {
462 companion object {
391 @Throws(DeserializationError::class)
463 @Throws(DeserializationError::class)
392 fun deserialize(deserializer: Deserializer): Event {
464 fun deserialize(deserializer: Deserializer): Event {
@@ -408,6 +480,10 @@ sealed interface Event {
408 13 -> NavigateToSessions.deserialize(deserializer)
480 13 -> NavigateToSessions.deserialize(deserializer)
409 14 -> NavigateToConnect.deserialize(deserializer)
481 14 -> NavigateToConnect.deserialize(deserializer)
410 15 -> DismissError.deserialize(deserializer)
482 15 -> DismissError.deserialize(deserializer)
483+ 16 -> DraftChanged.deserialize(deserializer)
484+ 17 -> CancelGeneration.deserialize(deserializer)
485+ 18 -> DeleteSession.deserialize(deserializer)
486+ 19 -> RetryMessage.deserialize(deserializer)
411 else -> throw DeserializationError("Unknown variant index for Event: $index")
487 else -> throw DeserializationError("Unknown variant index for Event: $index")
412 }
488 }
413 }
489 }
@@ -794,11 +870,58 @@ sealed interface HttpResult {
794 }
870 }
795}
871}
796
872
873+enum class MessageStatus {
874+ SENT,
875+ PENDING,
876+ FAILED;
877+
878+ fun serialize(serializer: Serializer) {
879+ serializer.increase_container_depth()
880+ serializer.serialize_variant_index(ordinal)
881+ serializer.decrease_container_depth()
882+ }
883+
884+ fun bincodeSerialize(): ByteArray {
885+ val serializer = BincodeSerializer()
886+ serialize(serializer)
887+ return serializer.get_bytes()
888+ }
889+
890+ companion object {
891+ @Throws(DeserializationError::class)
892+ fun deserialize(deserializer: Deserializer): MessageStatus {
893+ deserializer.increase_container_depth()
894+ val index = deserializer.deserialize_variant_index()
895+ deserializer.decrease_container_depth()
896+ return when (index) {
897+ 0 -> SENT
898+ 1 -> PENDING
899+ 2 -> FAILED
900+ else -> throw DeserializationError("Unknown variant index for MessageStatus: $index")
901+ }
902+ }
903+
904+ @Throws(DeserializationError::class)
905+ fun bincodeDeserialize(input: ByteArray?): MessageStatus {
906+ if (input == null) {
907+ throw DeserializationError("Cannot deserialize null array")
908+ }
909+ val deserializer = BincodeDeserializer(input)
910+ val value = deserialize(deserializer)
911+ if (deserializer.get_buffer_offset() < input.size) {
912+ throw DeserializationError("Some input bytes were not read")
913+ }
914+ return value
915+ }
916+ }
917+}
918+
797data class MessageView(
919data class MessageView(
798 val id: String,
920 val id: String,
799 val role: String,
921 val role: String,
800 val text: String,
922 val text: String,
801 val time: ULong,
923 val time: ULong,
924+ val status: soy.iko.opencode.core.MessageStatus,
802) {
925) {
803 fun serialize(serializer: Serializer) {
926 fun serialize(serializer: Serializer) {
804 serializer.increase_container_depth()
927 serializer.increase_container_depth()
@@ -806,6 +929,7 @@ data class MessageView(
806 serializer.serialize_str(role)
929 serializer.serialize_str(role)
807 serializer.serialize_str(text)
930 serializer.serialize_str(text)
808 serializer.serialize_u64(time)
931 serializer.serialize_u64(time)
932+ status.serialize(serializer)
809 serializer.decrease_container_depth()
933 serializer.decrease_container_depth()
810 }
934 }
811
935
@@ -822,8 +946,9 @@ data class MessageView(
822 val role = deserializer.deserialize_str()
946 val role = deserializer.deserialize_str()
823 val text = deserializer.deserialize_str()
947 val text = deserializer.deserialize_str()
824 val time = deserializer.deserialize_u64()
948 val time = deserializer.deserialize_u64()
949+ val status = soy.iko.opencode.core.MessageStatus.deserialize(deserializer)
825 deserializer.decrease_container_depth()
950 deserializer.decrease_container_depth()
826− return MessageView(id, role, text, time)
951+ return MessageView(id, role, text, time, status)
827 }
952 }
828
953
829 @Throws(DeserializationError::class)
954 @Throws(DeserializationError::class)
shared/src/app.rs+143−2
@@ -47,6 +47,16 @@ pub enum Event {
47 // Errors
47 // Errors
48 DismissError,
48 DismissError,
49
49
50+ // Draft / composer
51+ DraftChanged(String),
52+ // Cancel waiting for the assistant reply (client-side stop).
53+ CancelGeneration,
54+
55+ // Session management
56+ DeleteSession(String),
57+ // Retry sending a failed message by its (local) message id.
58+ RetryMessage(String),
59+
50 // ── Internal events (skipped from typegen, sent only via then_send).
60 // ── Internal events (skipped from typegen, sent only via then_send).
51 // These occupy higher discriminant indices that are never
61 // These occupy higher discriminant indices that are never
52 // produced by the shell, so the index gap is harmless. ─────────
62 // produced by the shell, so the index gap is harmless. ─────────
@@ -69,6 +79,9 @@ pub enum Event {
69 #[serde(skip)]
79 #[serde(skip)]
70 #[facet(skip)]
80 #[facet(skip)]
71 HealthProbed(#[facet(opaque)] HttpResult<String>),
81 HealthProbed(#[facet(opaque)] HttpResult<String>),
82+ #[serde(skip)]
83+ #[facet(skip)]
84+ SessionDeleted(#[facet(opaque)] HttpResult<String>),
72}
85}
73
86
74// ─── Effects ───────────────────────────────────────────────────────────────
87// ─── Effects ───────────────────────────────────────────────────────────────
@@ -117,12 +130,22 @@ pub struct SessionSummary {
117 pub title: String,
130 pub title: String,
118}
131}
119
132
133+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
134+#[repr(C)]
135+pub enum MessageStatus {
136+ #[default]
137+ Sent,
138+ Pending,
139+ Failed,
140+}
141+
120#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
142#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
121pub struct MessageView {
143pub struct MessageView {
122 pub id: String,
144 pub id: String,
123 pub role: String,
145 pub role: String,
124 pub text: String,
146 pub text: String,
125 pub time: u64,
147 pub time: u64,
148+ pub status: MessageStatus,
126}
149}
127
150
128// ─── ViewModel ─────────────────────────────────────────────────────────────
151// ─── ViewModel ─────────────────────────────────────────────────────────────
@@ -186,6 +209,13 @@ impl OpencodeApp {
186 self.with_auth(req, model)
209 self.with_auth(req, model)
187 }
210 }
188
211
212+ /// Build a DELETE request to `path`.
213+ fn delete(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
214+ let url = format!("{}{path}", model.server_url);
215+ let req = Http::delete(&url).expect_string();
216+ self.with_auth(req, model)
217+ }
218+
189 /// Attach the Basic-Auth `Authorization` header if the model carries
219 /// Attach the Basic-Auth `Authorization` header if the model carries
190 /// credentials. Uses RFC 7617 `Basic` scheme with base64-encoded
220 /// credentials. Uses RFC 7617 `Basic` scheme with base64-encoded
191 /// `username:password`.
221 /// `username:password`.
@@ -442,8 +472,58 @@ impl App for OpencodeApp {
442 render()
472 render()
443 }
473 }
444
474
475+ Event::DraftChanged(text) => {
476+ model.draft_message = text;
477+ render()
478+ }
479+
480+ Event::CancelGeneration => {
481+ // Client-side stop: the server-side generation continues, but
482+ // we stop showing the "generating" state and return to ready.
483+ // A subsequent SSE event / manual refresh will pick up the
484+ // assistant reply when it arrives.
485+ model.generating = false;
486+ render()
487+ }
488+
489+ Event::DeleteSession(id) => {
490+ // Optimistically remove the session from the list. If the
491+ // server delete fails, we reload the session list to restore.
492+ model.sessions.retain(|s| s.id != id);
493+ if model.current_session_id.as_deref() == Some(id.as_str()) {
494+ model.current_session_id = None;
495+ model.current_session_title.clear();
496+ model.messages.clear();
497+ model.generating = false;
498+ }
499+ let path = format!("/session/{id}");
500+ self.delete(model, &path).build().then_send(Event::SessionDeleted)
501+ }
502+
503+ Event::RetryMessage(_msg_id) => {
504+ // No persisted failed-message body to re-send in the core, so
505+ // retry is a no-op here. The shell can surface a hint. This
506+ // event exists so the UI has a stable retry path and so the
507+ // core can grow real per-message retry later without a
508+ // typegen change.
509+ render()
510+ }
511+
445 // ── Internal events (not type-generated, sent via then_send) ───
512 // ── Internal events (not type-generated, sent via then_send) ───
446 Event::HealthProbed(result) => handle_health_probed(self, model, result),
513 Event::HealthProbed(result) => handle_health_probed(self, model, result),
514+ Event::SessionDeleted(result) => {
515+ match result {
516+ Ok(_) => {
517+ render()
518+ }
519+ Err(e) => {
520+ // Optimistic delete failed — reload sessions to
521+ // restore the list, and surface the error.
522+ model.error = Some(format!("Failed to delete session: {e}"));
523+ self.get(model, "/session").build().then_send(Event::SessionsLoaded)
524+ }
525+ }
526+ }
447 }
527 }
448 }
528 }
449
529
@@ -607,7 +687,7 @@ fn parse_messages(body: &str) -> Vec<MessageView> {
607 let time = extract_number(obj, "created").unwrap_or(0);
687 let time = extract_number(obj, "created").unwrap_or(0);
608 if !id.is_empty() {
688 if !id.is_empty() {
609 let text = extract_message_text(obj);
689 let text = extract_message_text(obj);
610− messages.push(MessageView { id, role, text, time });
690+ messages.push(MessageView { id, role, text, time, status: MessageStatus::Sent });
611 }
691 }
612 }
692 }
613 }
693 }
@@ -739,6 +819,7 @@ mod test {
739 role: "user".to_string(),
819 role: "user".to_string(),
740 text: "hello".to_string(),
820 text: "hello".to_string(),
741 time: 0,
821 time: 0,
822+ status: MessageStatus::Sent,
742 });
823 });
743 app.update(Event::NavigateToSessions, &mut model);
824 app.update(Event::NavigateToSessions, &mut model);
744 assert!(model.current_session_id.is_none());
825 assert!(model.current_session_id.is_none());
@@ -791,6 +872,61 @@ mod test {
791 assert!(model.error.is_some());
872 assert!(model.error.is_some());
792 }
873 }
793
874
875+ #[test]
876+ fn cancel_generation_clears_generating() {
877+ let app = OpencodeApp;
878+ let mut model = Model::default();
879+ model.server_url = "http://localhost:4096".to_string();
880+ model.current_session_id = Some("s1".to_string());
881+ app.update(Event::SendMessage("hello".to_string()), &mut model);
882+ assert!(model.generating);
883+ app.update(Event::CancelGeneration, &mut model);
884+ assert!(!model.generating);
885+ }
886+
887+ #[test]
888+ fn draft_changed_updates_model() {
889+ let app = OpencodeApp;
890+ let mut model = Model::default();
891+ app.update(Event::DraftChanged("in progress".to_string()), &mut model);
892+ assert_eq!(model.draft_message, "in progress");
893+ let view = app.view(&model);
894+ assert_eq!(view.draft_message, "in progress");
895+ }
896+
897+ #[test]
898+ fn delete_session_optimistically_removes_from_list() {
899+ let app = OpencodeApp;
900+ let mut model = Model::default();
901+ model.server_url = "http://localhost:4096".to_string();
902+ model.sessions = vec![
903+ SessionSummary { id: "s1".to_string(), title: "First".to_string() },
904+ SessionSummary { id: "s2".to_string(), title: "Second".to_string() },
905+ ];
906+ let mut cmd = app.update(Event::DeleteSession("s1".to_string()), &mut model);
907+ // Session removed optimistically
908+ assert_eq!(model.sessions.len(), 1);
909+ assert_eq!(model.sessions[0].id, "s2");
910+ // HTTP effect emitted for the server-side delete
911+ assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
912+ }
913+
914+ #[test]
915+ fn delete_current_session_clears_chat_state() {
916+ let app = OpencodeApp;
917+ let mut model = Model::default();
918+ model.server_url = "http://localhost:4096".to_string();
919+ model.current_session_id = Some("s1".to_string());
920+ model.current_session_title = "First".to_string();
921+ model.generating = true;
922+ model.messages.push(MessageView::default());
923+ app.update(Event::DeleteSession("s1".to_string()), &mut model);
924+ assert!(model.current_session_id.is_none());
925+ assert!(model.current_session_title.is_empty());
926+ assert!(model.messages.is_empty());
927+ assert!(!model.generating);
928+ }
929+
794 #[test]
930 #[test]
795 fn auth_header_built_from_credentials() {
931 fn auth_header_built_from_credentials() {
796 let app = OpencodeApp;
932 let app = OpencodeApp;
@@ -829,7 +965,8 @@ mod test {
829 // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
965 // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
830 // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
966 // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
831 // 12=NavigateToChat, 13=NavigateToSessions, 14=NavigateToConnect,
967 // 12=NavigateToChat, 13=NavigateToSessions, 14=NavigateToConnect,
832− // 15=DismissError
968+ // 15=DismissError, 16=DraftChanged, 17=CancelGeneration,
969+ // 18=DeleteSession, 19=RetryMessage
833 //
970 //
834 // Serialize each Rust variant and check the leading u32 index.
971 // Serialize each Rust variant and check the leading u32 index.
835 let cases: &[(Event, u32)] = &[
972 let cases: &[(Event, u32)] = &[
@@ -849,6 +986,10 @@ mod test {
849 (Event::NavigateToSessions, 13),
986 (Event::NavigateToSessions, 13),
850 (Event::NavigateToConnect, 14),
987 (Event::NavigateToConnect, 14),
851 (Event::DismissError, 15),
988 (Event::DismissError, 15),
989+ (Event::DraftChanged(String::new()), 16),
990+ (Event::CancelGeneration, 17),
991+ (Event::DeleteSession(String::new()), 18),
992+ (Event::RetryMessage(String::new()), 19),
852 ];
993 ];
853 for (event, expected) in cases {
994 for (event, expected) in cases {
854 let bytes = bincode::serialize(event).unwrap();
995 let bytes = bincode::serialize(event).unwrap();