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
commitaed5cef042e16fd3abe9baf0d7d8942c3a11ee48 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 12:32 (3 months ago) parent8d923dd
19 files changed+917−113
app/src/main/java/soy/iko/opencode/MainActivity.kt+44−25 app/src/main/java/soy/iko/opencode/core/Core.kt+11−1 app/src/main/java/soy/iko/opencode/core/PureCoreFfi.kt+62−14 app/src/main/java/soy/iko/opencode/ui/components/ChatInputBar.kt+16−6 app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.kt+72−11 app/src/main/java/soy/iko/opencode/ui/components/DateSeparator.kt+23−5 app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.kt+2 app/src/main/java/soy/iko/opencode/ui/components/LoadingPlaceholder.kt+3−1 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+36−8 app/src/main/java/soy/iko/opencode/ui/components/SseDisconnectedBanner.kt+39 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+82−23 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+4−3 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+105−11 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+2−2 app/src/main/res/values/strings.xml+31 app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.kt+55 flake.lock+61 generated/soy/iko/opencode/core/Core.kt+126−1 shared/src/app.rs+143−2
app/src/main/java/soy/iko/opencode/MainActivity.kt+44−25
@@ -1,7 +1,6 @@
11package soy.iko.opencode
22
33import android.os.Bundle
4−import android.widget.Toast
54import androidx.activity.ComponentActivity
65import androidx.activity.compose.setContent
76import androidx.activity.enableEdgeToEdge
@@ -12,13 +11,20 @@ import androidx.compose.animation.fadeIn
1211import androidx.compose.animation.fadeOut
1312import androidx.compose.animation.slideInHorizontally
1413import androidx.compose.animation.slideOutHorizontally
14+import androidx.compose.material3.SnackbarDuration
15+import androidx.compose.material3.SnackbarHost
16+import androidx.compose.material3.SnackbarHostState
1517import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.LaunchedEffect
1619import androidx.compose.runtime.collectAsState
1720import androidx.compose.runtime.getValue
1821import androidx.compose.runtime.mutableLongStateOf
1922import androidx.compose.runtime.remember
2023import androidx.compose.runtime.setValue
24+import androidx.compose.foundation.layout.padding
25+import androidx.compose.ui.Modifier
2126import androidx.compose.ui.platform.LocalContext
27+import androidx.compose.ui.res.stringResource
2228import androidx.lifecycle.viewmodel.compose.viewModel
2329import soy.iko.opencode.core.Core
2430import soy.iko.opencode.core.Event
@@ -45,10 +51,12 @@ fun App(core: Core = viewModel()) {
4551 val view by core.view.collectAsState()
4652 val context = LocalContext.current
4753 var lastBackPress by remember { mutableLongStateOf(0L) }
54+ val snackbarHostState = remember { SnackbarHostState() }
55+ val exitHint = stringResource(R.string.press_back_again_to_exit)
4856
4957 // Map the system back button to logical in-app navigation so it never
5058 // 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).
5260 BackHandler(enabled = true) {
5361 when (view.screen) {
5462 Screen.CHAT -> core.update(Event.NavigateToSessions)
@@ -59,42 +67,53 @@ fun App(core: Core = viewModel()) {
5967 (context as? android.app.Activity)?.finish()
6068 } else {
6169 lastBackPress = now
62− Toast.makeText(
63− context,
64− R.string.press_back_again_to_exit,
65− Toast.LENGTH_SHORT,
66− ).show()
6770 }
6871 }
6972 }
7073 }
7174
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+
7286 // Directional slide between screens for a smoother navigation feel:
7387 // forward navigation slides the new screen in from the end side, back
7488 // navigation slides it in from the start side. A short fade is layered on
7589 // so the transition doesn't feel harsh. The TopAppBar still fades with
7690 // each screen, but the directional motion reads as natural page changes
7791 // rather than a flat crossfade.
78− AnimatedContent(
79− targetState = view.screen,
80− transitionSpec = {
81− val forward = targetState.ordinal > initialState.ordinal
82− if (forward) {
83− slideInHorizontally(initialOffsetX = { it / 3 }) + fadeIn() togetherWith
84− slideOutHorizontally(targetOffsetX = { -it / 6 }) + fadeOut()
85− } else {
86− slideInHorizontally(initialOffsetX = { -it / 6 }) + fadeIn() togetherWith
87− slideOutHorizontally(targetOffsetX = { it / 3 }) + fadeOut()
92+ androidx.compose.material3.Scaffold(
93+ snackbarHost = { SnackbarHost(snackbarHostState) },
94+ ) { padding ->
95+ AnimatedContent(
96+ targetState = view.screen,
97+ transitionSpec = {
98+ val forward = targetState.ordinal > initialState.ordinal
99+ if (forward) {
100+ slideInHorizontally(initialOffsetX = { it / 3 }) + fadeIn() togetherWith
101+ slideOutHorizontally(targetOffsetX = { -it / 6 }) + fadeOut()
102+ } else {
103+ slideInHorizontally(initialOffsetX = { -it / 6 }) + fadeIn() togetherWith
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)
88114 }
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)
96115 }
97116 }
98117}
99118
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) {
102102 viewModelScope.launch { _info.emit(SUCCESS_COPIED) }
103103 }
104104
105+ /** Surface a transient "Session deleted" snackbar to the user. */
106+ fun notifyDeleted() {
107+ viewModelScope.launch { _info.emit(SUCCESS_SESSION_DELETED) }
108+ }
109+
105110 private fun processEffect(request: Request) {
106111 when (val effect = request.effect) {
107112 is Effect.Render -> {
@@ -149,6 +154,9 @@ class Core(application: Application) : AndroidViewModel(application) {
149154 lastUserEvent is Event.CreateSession -> {
150155 viewModelScope.launch { _info.emit(SUCCESS_SESSION_CREATED) }
151156 }
157+ lastUserEvent is Event.DeleteSession && v.error == null -> {
158+ viewModelScope.launch { _info.emit(SUCCESS_SESSION_DELETED) }
159+ }
152160 }
153161 previousScreen = v.screen
154162 }
@@ -213,7 +221,8 @@ class Core(application: Application) : AndroidViewModel(application) {
213221 is Event.LoadSessions,
214222 is Event.CreateSession,
215223 is Event.LoadMessages,
216− is Event.SendMessage -> true
224+ is Event.SendMessage,
225+ is Event.DeleteSession -> true
217226 else -> false
218227 }
219228
@@ -224,5 +233,6 @@ class Core(application: Application) : AndroidViewModel(application) {
224233 const val SUCCESS_CONNECTED = "__success_connected__"
225234 const val SUCCESS_SESSION_CREATED = "__success_session_created__"
226235 const val SUCCESS_COPIED = "__success_copied__"
236+ const val SUCCESS_SESSION_DELETED = "__success_session_deleted__"
227237 }
228238}
app/src/main/java/soy/iko/opencode/core/PureCoreFfi.kt+62−14
@@ -165,7 +165,30 @@ internal data class PureModel(
165165 is Event.DismissError -> {
166166 error = null
167167 }
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+ }
169192 }
170193 return Requests(requests)
171194 }
@@ -274,6 +297,22 @@ internal data class PureModel(
274297 }
275298 }
276299 }
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+ }
277316 }
278317 return Requests(requests)
279318 }
@@ -296,7 +335,7 @@ internal data class PureModel(
296335 sessions = sessions.map { SessionView(it.id, it.title) },
297336 currentSessionId = currentSessionId,
298337 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) },
300339 draftMessage = draftMessage,
301340 generating = generating,
302341 crashLogCount = crashLogs.size.toUInt(),
@@ -305,26 +344,28 @@ internal data class PureModel(
305344 }
306345
307346 private fun makeGetRequest(url: String): HttpRequest {
308− val headers = if (username.isNotEmpty() || password.isNotEmpty()) {
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− }
347+ val headers = authHeaders()
315348 return HttpRequest(method = "GET", url = url, headers = headers, body = Bytes(ByteArray(0)))
316349 }
317350
318351 private fun makePostRequest(url: String, body: ByteArray): HttpRequest {
319− val headers = if (username.isNotEmpty() || password.isNotEmpty()) {
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> =
362+ if (username.isNotEmpty() || password.isNotEmpty()) {
320363 val creds = "$username:$password"
321364 val encoded = java.util.Base64.getEncoder().encodeToString(creds.toByteArray())
322365 listOf(HttpHeader("Authorization", "Basic $encoded"))
323366 } else {
324367 emptyList()
325368 }
326− return HttpRequest(method = "POST", url = url, headers = headers, body = Bytes(body))
327− }
328369}
329370
330371internal sealed class PendingRequest {
@@ -333,11 +374,18 @@ internal sealed class PendingRequest {
333374 data object CreateSession : PendingRequest()
334375 data class LoadMessages(val sessionId: String) : PendingRequest()
335376 data class SendMessage(val sessionId: String) : PendingRequest()
377+ data class DeleteSession(val sessionId: String) : PendingRequest()
336378}
337379
338380internal data class PureSession(val id: String, val title: String)
339381
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+)
341389
342390internal fun parseSessions(json: String): List<PureSession> {
343391 val sessions = mutableListOf<PureSession>()
@@ -384,7 +432,7 @@ internal fun parseMessages(json: String): List<PureMessage> {
384432 val time = extractJsonNumber(obj, "created") ?: 0uL
385433 if (id.isNotEmpty() && role.isNotEmpty()) {
386434 val text = extractMessageText(obj)
387− messages.add(PureMessage(id, role, text, time))
435+ messages.add(PureMessage(id, role, text, time, MessageStatus.SENT))
388436 }
389437 }
390438 }
app/src/main/java/soy/iko/opencode/ui/components/ChatInputBar.kt+16−6
@@ -22,6 +22,8 @@ import androidx.compose.material3.Text
2222import androidx.compose.runtime.Composable
2323import androidx.compose.ui.Alignment
2424import androidx.compose.ui.Modifier
25+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
26+import androidx.compose.ui.platform.LocalHapticFeedback
2527import androidx.compose.ui.semantics.contentDescription
2628import androidx.compose.ui.semantics.semantics
2729import androidx.compose.ui.text.input.ImeAction
@@ -35,12 +37,14 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
3537 *
3638 * - Honors IME insets via [Modifier.imePadding] so the keyboard never covers
3739 * the input bar (the chat Scaffold relies on this).
38− * - Exposes a keyboard "Send" action so users can fire-and-forget without
39− * tapping the icon.
40+ * - Uses [ImeAction.Default] so the keyboard's action key inserts a newline,
41+ * allowing multi-line messages. The send button on the right fires the
42+ * message.
4043 * - Send is disabled while [enabled] is false (e.g. during loading) or when
4144 * the text is blank.
4245 * - When [generating] is true, a Stop button replaces the Send button so the
4346 * user can cancel the wait; tapping it calls [onStop].
47+ * - A long-press haptic fires on send for tactile confirmation.
4448 */
4549@Composable
4650fun ChatInputBar(
@@ -55,6 +59,7 @@ fun ChatInputBar(
5559 placeholder: String = "Message…",
5660 sendContentDescription: String = "Send",
5761) {
62+ val hapticFeedback = LocalHapticFeedback.current
5863 Surface(
5964 tonalElevation = Dimens.inputBarTonalElevation,
6065 modifier = modifier
@@ -78,12 +83,14 @@ fun ChatInputBar(
7883 placeholder = { Text(placeholder) },
7984 modifier = Modifier.weight(1f),
8085 maxLines = 4,
81− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
82− keyboardActions = KeyboardActions(onSend = { onSend() }),
86+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default),
8387 )
8488 if (generating) {
8589 IconButton(
86− onClick = onStop,
90+ onClick = {
91+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
92+ onStop()
93+ },
8794 modifier = Modifier.semantics {
8895 contentDescription = stopContentDescription
8996 },
@@ -92,7 +99,10 @@ fun ChatInputBar(
9299 }
93100 } else {
94101 IconButton(
95− onClick = onSend,
102+ onClick = {
103+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
104+ onSend()
105+ },
96106 enabled = enabled && text.isNotBlank(),
97107 modifier = Modifier.semantics {
98108 contentDescription = sendContentDescription
app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.kt+72−11
@@ -1,11 +1,14 @@
11package soy.iko.opencode.ui.components
22
3+import android.content.Intent
34import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
46import androidx.compose.foundation.layout.Spacer
57import androidx.compose.foundation.layout.fillMaxWidth
68import androidx.compose.foundation.layout.height
79import androidx.compose.foundation.layout.heightIn
810import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.size
912import androidx.compose.foundation.rememberScrollState
1013import androidx.compose.foundation.verticalScroll
1114import androidx.compose.material3.AlertDialog
@@ -13,13 +16,19 @@ import androidx.compose.material3.MaterialTheme
1316import androidx.compose.material3.Text
1417import androidx.compose.material3.TextButton
1518import androidx.compose.runtime.Composable
19+import androidx.compose.runtime.LaunchedEffect
1620import androidx.compose.runtime.getValue
1721import androidx.compose.runtime.mutableStateOf
1822import androidx.compose.runtime.remember
1923import androidx.compose.runtime.setValue
2024import androidx.compose.ui.Modifier
25+import androidx.compose.ui.platform.LocalContext
26+import androidx.compose.ui.res.stringResource
2127import androidx.compose.ui.tooling.preview.Preview
2228import androidx.compose.ui.unit.dp
29+import kotlinx.coroutines.Dispatchers
30+import kotlinx.coroutines.withContext
31+import soy.iko.opencode.R
2332import soy.iko.opencode.ui.theme.Dimens
2433import soy.iko.opencode.ui.theme.OpencodeTheme
2534import java.io.File
@@ -30,9 +39,8 @@ import java.io.File
3039 * capped at 60% of the viewport via [Modifier.heightIn] so very long traces
3140 * scroll internally without consuming the whole screen.
3241 *
33− * Crash report file contents are read once via [remember] (off the first
34− * recomposition) rather than on every recomposition — [File.readText] is disk
35− * I/O and shouldn't run in composition repeatedly.
42+ * Crash report file contents are read asynchronously via [LaunchedEffect] on
43+ * [Dispatchers.IO] so disk I/O never blocks the composition thread.
3644 *
3745 * "Clear all" is a destructive action guarded by a confirmation step. The
3846 * single dialog swaps its content and buttons between the list view and the
@@ -40,6 +48,8 @@ import java.io.File
4048 *
4149 * - List view: dismiss = "Clear all" (error-tinted), confirm = "Close"
4250 * - Confirm view: dismiss = "Cancel", confirm = "Clear" (error-tinted)
51+ *
52+ * Each report has a "Copy" and "Share" button for filing bug reports.
4353 */
4454@Composable
4555fun CrashLogDialog(
@@ -53,12 +63,21 @@ fun CrashLogDialog(
5363 cancelLabel: String = "Cancel",
5464 clearConfirmLabel: String = "Clear all crash reports?",
5565 clearConfirmYesLabel: String = "Clear",
66+ copyLabel: String = "Copy",
67+ shareLabel: String = "Share",
68+ copiedLabel: String = "Crash report copied",
69+ emptyLabel: String = "No crash reports",
5670) {
57− // Read each report's text once, not on every recomposition.
58− val contents = remember(reports) {
59− reports.map { it.name to it.readText() }
71+ // Read crash report contents asynchronously on Dispatchers.IO so disk
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) {
76+ reports.map { it.name to it.readText() }
77+ }
6078 }
6179 var confirmingClear by remember { mutableStateOf(false) }
80+ val context = LocalContext.current
6281
6382 AlertDialog(
6483 onDismissRequest = {
@@ -72,6 +91,12 @@ fun CrashLogDialog(
7291 text = clearConfirmLabel,
7392 style = MaterialTheme.typography.bodyMedium,
7493 )
94+ } else if (contents.isEmpty()) {
95+ Text(
96+ text = emptyLabel,
97+ style = MaterialTheme.typography.bodyMedium,
98+ color = MaterialTheme.colorScheme.onSurfaceVariant,
99+ )
75100 } else {
76101 Column(
77102 modifier = Modifier
@@ -80,11 +105,47 @@ fun CrashLogDialog(
80105 .verticalScroll(rememberScrollState()),
81106 ) {
82107 contents.forEachIndexed { index, (name, text) ->
83− Text(
84− text = name,
85− style = MaterialTheme.typography.labelMedium,
86− color = MaterialTheme.colorScheme.primary,
87− )
108+ Row(
109+ modifier = Modifier.fillMaxWidth(),
110+ horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
111+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
112+ ) {
113+ Text(
114+ text = name,
115+ style = MaterialTheme.typography.labelMedium,
116+ color = MaterialTheme.colorScheme.primary,
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+ }
88149 Spacer(Modifier.height(Dimens.spaceTiny))
89150 Text(
90151 text = text,
app/src/main/java/soy/iko/opencode/ui/components/DateSeparator.kt+23−5
@@ -11,11 +11,13 @@ import androidx.compose.runtime.remember
1111import androidx.compose.ui.Alignment
1212import androidx.compose.ui.Modifier
1313import androidx.compose.ui.platform.LocalContext
14+import androidx.compose.ui.res.stringResource
1415import androidx.compose.ui.semantics.contentDescription
1516import androidx.compose.ui.semantics.semantics
1617import androidx.compose.ui.text.style.TextAlign
1718import androidx.compose.ui.tooling.preview.Preview
1819import androidx.compose.ui.unit.dp
20+import soy.iko.opencode.R
1921import soy.iko.opencode.ui.theme.OpencodeTheme
2022import java.util.Calendar
2123import java.util.Date
@@ -29,7 +31,9 @@ import java.util.Locale
2931@Composable
3032fun DateSeparator(epochSeconds: ULong, modifier: Modifier = Modifier) {
3133 val context = LocalContext.current
32− val label = remember(epochSeconds) { formatDateLabel(epochSeconds, context) }
34+ val todayLabel = stringResource(R.string.date_today)
35+ val yesterdayLabel = stringResource(R.string.date_yesterday)
36+ val label = remember(epochSeconds) { formatDateLabel(epochSeconds, context, todayLabel, yesterdayLabel) }
3337 Box(
3438 modifier = modifier
3539 .fillMaxWidth()
@@ -46,10 +50,24 @@ fun DateSeparator(epochSeconds: ULong, modifier: Modifier = Modifier) {
4650 }
4751}
4852
49−private fun formatDateLabel(epochSeconds: ULong, context: android.content.Context): String {
53+private fun formatDateLabel(
54+ epochSeconds: ULong,
55+ context: android.content.Context,
56+ todayLabel: String,
57+ yesterdayLabel: String,
58+): String {
5059 val date = Date(epochSeconds.toLong() * 1000)
51− val formatter = DateFormat.getLongDateFormat(context)
52− return formatter.format(date)
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 -> {
67+ val formatter = DateFormat.getLongDateFormat(context)
68+ formatter.format(date)
69+ }
70+ }
5371}
5472
5573/**
@@ -57,7 +75,7 @@ private fun formatDateLabel(epochSeconds: ULong, context: android.content.Contex
5775 * in the device's default timezone. Returns 0 for time == 0 (unknown).
5876 */
5977internal fun dayOfEpoch(epochSeconds: ULong): Int {
60− if (epochSeconds == 0uL) return 0
78+ if (epochSeconds == 0uL) return Int.MIN_VALUE
6179 val cal = Calendar.getInstance(Locale.getDefault())
6280 cal.timeInMillis = epochSeconds.toLong() * 1000
6381 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(
7373 successConnectedLabel: String,
7474 successSessionCreatedLabel: String,
7575 copiedLabel: String = "Copied",
76+ sessionDeletedLabel: String = "Session deleted",
7677): SnackbarHostState {
7778 val snackbarHostState = remember { SnackbarHostState() }
7879 LaunchedEffect(snackbarHostState) {
@@ -81,6 +82,7 @@ fun InfoHost(
8182 Core.SUCCESS_CONNECTED -> successConnectedLabel
8283 Core.SUCCESS_SESSION_CREATED -> successSessionCreatedLabel
8384 Core.SUCCESS_COPIED -> copiedLabel
85+ Core.SUCCESS_SESSION_DELETED -> sessionDeletedLabel
8486 else -> key
8587 }
8688 snackbarHostState.showSnackbar(
app/src/main/java/soy/iko/opencode/ui/components/LoadingPlaceholder.kt+3−1
@@ -11,12 +11,14 @@ import androidx.compose.material3.Text
1111import androidx.compose.runtime.Composable
1212import androidx.compose.ui.Alignment
1313import androidx.compose.ui.Modifier
14+import androidx.compose.ui.res.stringResource
1415import androidx.compose.ui.semantics.LiveRegionMode
1516import androidx.compose.ui.semantics.contentDescription
1617import androidx.compose.ui.semantics.liveRegion
1718import androidx.compose.ui.semantics.semantics
1819import androidx.compose.ui.text.style.TextAlign
1920import androidx.compose.ui.tooling.preview.Preview
21+import soy.iko.opencode.R
2022import soy.iko.opencode.ui.theme.Dimens
2123import soy.iko.opencode.ui.theme.OpencodeTheme
2224
@@ -28,7 +30,7 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
2830@Composable
2931fun LoadingPlaceholder(
3032 modifier: Modifier = Modifier,
31− contentDescription: String = "Loading",
33+ contentDescription: String = stringResource(R.string.loading),
3234) {
3335 Box(
3436 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
1717import androidx.compose.foundation.layout.padding
1818import androidx.compose.foundation.layout.size
1919import 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
2024import androidx.compose.material3.MaterialTheme
2125import androidx.compose.material3.Surface
2226import androidx.compose.material3.Text
2327import androidx.compose.runtime.Composable
2428import androidx.compose.runtime.getValue
2529import androidx.compose.runtime.remember
30+import androidx.compose.ui.Alignment
2631import androidx.compose.ui.Modifier
2732import androidx.compose.ui.hapticfeedback.HapticFeedbackType
2833import androidx.compose.ui.platform.LocalClipboardManager
@@ -41,6 +46,7 @@ import androidx.compose.ui.tooling.preview.Preview
4146import androidx.compose.ui.unit.dp
4247import com.mikepenz.markdown.m3.Markdown
4348import soy.iko.opencode.R
49+import soy.iko.opencode.core.MessageStatus
4450import soy.iko.opencode.core.MessageView
4551import soy.iko.opencode.ui.theme.AssistantBubbleShape
4652import soy.iko.opencode.ui.theme.Dimens
@@ -152,18 +158,38 @@ fun MessageBubble(
152158 }
153159 Spacer(Modifier.height(Dimens.spaceTiny))
154160 } else {
155− // Show a timestamp on user bubbles too, aligned to the
156− // end so it reads as the message's send time.
157− if (message.time > 0uL) {
161+ // Show a timestamp and status indicator on user bubbles,
162+ // aligned to the end so it reads as the message's send time.
163+ val showStatus = message.status != MessageStatus.SENT
164+ if (message.time > 0uL || showStatus) {
158165 Row(
159166 modifier = Modifier.fillMaxWidth(),
160167 horizontalArrangement = Arrangement.End,
168+ verticalAlignment = Alignment.CenterVertically,
161169 ) {
162− Text(
163− text = timeLabel,
164− style = MaterialTheme.typography.labelSmall,
165− color = MaterialTheme.colorScheme.onPrimary,
166− )
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) {
187+ Text(
188+ text = timeLabel,
189+ style = MaterialTheme.typography.labelSmall,
190+ color = MaterialTheme.colorScheme.onPrimary,
191+ )
192+ }
167193 }
168194 Spacer(Modifier.height(Dimens.spaceTiny))
169195 }
@@ -328,6 +354,7 @@ private fun MessageBubbleUserPreview() {
328354 role = "user",
329355 text = "Hello! Can you summarize the build steps?",
330356 time = 1_700_000_000uL,
357+ status = MessageStatus.SENT,
331358 ),
332359 )
333360 MessageBubble(
@@ -336,6 +363,7 @@ private fun MessageBubbleUserPreview() {
336363 role = "assistant",
337364 text = "Sure — here's a **short** list:\n\n1. Generate types\n2. Build the native library\n3. Assemble the APK",
338365 time = 1_700_000_010uL,
366+ status = MessageStatus.SENT,
339367 ),
340368 )
341369 }
app/src/main/java/soy/iko/opencode/ui/components/SseDisconnectedBanner.kt+39
@@ -93,3 +93,42 @@ private fun SseDisconnectedBannerPreview() {
9393 )
9494 }
9595}
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
33import androidx.compose.animation.AnimatedVisibility
44import androidx.compose.animation.scaleIn
55import androidx.compose.animation.scaleOut
6+import androidx.compose.foundation.ExperimentalFoundationApi
7+import androidx.compose.foundation.combinedClickable
68import androidx.compose.foundation.layout.Arrangement
79import androidx.compose.foundation.layout.Box
810import androidx.compose.foundation.layout.Column
911import androidx.compose.foundation.layout.PaddingValues
12+import androidx.compose.foundation.layout.Row
1013import androidx.compose.foundation.layout.fillMaxSize
14+import androidx.compose.foundation.layout.fillMaxWidth
1115import androidx.compose.foundation.layout.padding
16+import androidx.compose.foundation.layout.size
1217import androidx.compose.foundation.lazy.LazyColumn
1318import androidx.compose.foundation.lazy.items
1419import androidx.compose.foundation.lazy.rememberLazyListState
1520import androidx.compose.material.icons.Icons
1621import androidx.compose.material.icons.automirrored.filled.ArrowBack
22+import androidx.compose.material.icons.filled.ContentCopy
1723import androidx.compose.material.icons.filled.KeyboardArrowDown
1824import androidx.compose.material.icons.filled.MoreVert
1925import androidx.compose.material.icons.filled.Refresh
@@ -35,18 +41,24 @@ import androidx.compose.runtime.getValue
3541import androidx.compose.runtime.mutableIntStateOf
3642import androidx.compose.runtime.mutableStateOf
3743import androidx.compose.runtime.remember
44+import androidx.compose.runtime.saveable.rememberSaveable
3845import androidx.compose.runtime.setValue
3946import androidx.compose.ui.Alignment
4047import androidx.compose.ui.Modifier
4148import androidx.compose.ui.focus.FocusRequester
4249import androidx.compose.ui.focus.focusRequester
50+import androidx.compose.ui.platform.LocalClipboardManager
4351import 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
4455import androidx.compose.ui.text.style.TextOverflow
4556import androidx.compose.ui.tooling.preview.Preview
4657import androidx.compose.ui.unit.dp
4758import soy.iko.opencode.R
4859import soy.iko.opencode.core.Core
4960import soy.iko.opencode.core.Event
61+import soy.iko.opencode.core.MessageStatus
5062import soy.iko.opencode.core.MessageView
5163import soy.iko.opencode.core.SseState
5264import soy.iko.opencode.ui.components.ChatInputBar
@@ -59,29 +71,36 @@ import soy.iko.opencode.ui.components.InfoHost
5971import soy.iko.opencode.ui.components.LoadingPlaceholder
6072import soy.iko.opencode.ui.components.MessageBubble
6173import soy.iko.opencode.ui.components.SseDisconnectedBanner
74+import soy.iko.opencode.ui.components.SseStatusBanner
6275import soy.iko.opencode.ui.components.dayOfEpoch
6376import soy.iko.opencode.ui.theme.Dimens
6477import soy.iko.opencode.ui.theme.OpencodeTheme
6578
66−@OptIn(ExperimentalMaterial3Api::class)
79+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
6780@Composable
6881fun ChatScreen(core: Core) {
6982 val view by core.view.collectAsState()
7083 val sseState by core.sseState.collectAsState()
71− var inputText by remember { mutableStateOf("") }
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.
87+ var inputText by remember(view.draftMessage) { mutableStateOf(view.draftMessage) }
7288 val listState = rememberLazyListState()
73− var scrollRequest by remember { mutableIntStateOf(0) }
74− var menuExpanded by remember { mutableStateOf(false) }
75− var sseBannerDismissed by remember { mutableStateOf(false) }
89+ var scrollRequest by rememberSaveable { mutableIntStateOf(0) }
90+ var menuExpanded by rememberSaveable { mutableStateOf(false) }
91+ var sseBannerDismissed by rememberSaveable { mutableStateOf(false) }
7692 val inputFocusRequester = remember { FocusRequester() }
93+ val clipboardManager = LocalClipboardManager.current
7794
7895 // Track whether the user is parked at the bottom of the list. We only
7996 // auto-scroll when they are, so reading history isn't yanked away as
8097 // new messages stream in.
8198 val isAtBottom by remember {
8299 derivedStateOf {
83− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
84− lastVisible >= view.messages.size - 2
100+ val layoutInfo = listState.layoutInfo
101+ val totalItems = layoutInfo.totalItemsCount
102+ val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
103+ totalItems == 0 || lastVisible >= totalItems - 2
85104 }
86105 }
87106
@@ -95,10 +114,7 @@ fun ChatScreen(core: Core) {
95114 // repeated taps keep firing.
96115 LaunchedEffect(scrollRequest) {
97116 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)
102118 }
103119 }
104120
@@ -122,6 +138,7 @@ fun ChatScreen(core: Core) {
122138 successConnectedLabel = stringResource(R.string.connect_success),
123139 successSessionCreatedLabel = stringResource(R.string.session_created),
124140 copiedLabel = stringResource(R.string.chat_copied),
141+ sessionDeletedLabel = stringResource(R.string.session_deleted),
125142 )
126143
127144 val showSseBanner = (sseState is SseState.Error ||
@@ -158,24 +175,53 @@ fun ChatScreen(core: Core) {
158175 val refreshLabel = stringResource(R.string.chat_menu_refresh)
159176 val overflowLabel = stringResource(R.string.chat_menu_overflow)
160177 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)
161180
162181 Scaffold(
163182 topBar = {
164183 TopAppBar(
165184 title = {
166− Column {
185+ Column(
186+ modifier = Modifier.fillMaxWidth(),
187+ ) {
167188 Text(
168189 text = title,
169190 maxLines = 1,
170191 overflow = TextOverflow.Ellipsis,
171192 )
172− if (subtitle != null) {
173− Text(
174− text = subtitle,
175− style = MaterialTheme.typography.labelSmall,
176− color = MaterialTheme.colorScheme.onSurfaceVariant,
177− maxLines = 1,
178− )
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+ ) {
212+ Text(
213+ text = subtitle,
214+ style = MaterialTheme.typography.labelSmall,
215+ color = MaterialTheme.colorScheme.onSurfaceVariant,
216+ maxLines = 1,
217+ )
218+ Icon(
219+ Icons.Default.ContentCopy,
220+ contentDescription = null,
221+ modifier = Modifier.size(Dimens.iconInlineSpinner),
222+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
223+ )
224+ }
179225 }
180226 }
181227 },
@@ -220,18 +266,22 @@ fun ChatScreen(core: Core) {
220266 bottomBar = {
221267 ChatInputBar(
222268 text = inputText,
223− onTextChange = { inputText = it },
269+ onTextChange = {
270+ inputText = it
271+ core.update(Event.DraftChanged(it))
272+ },
224273 onSend = {
225274 if (inputText.isNotBlank()) {
226275 core.update(Event.SendMessage(inputText))
227276 inputText = ""
277+ core.update(Event.DraftChanged(""))
228278 }
229279 },
230280 // Disable while loading OR while the assistant is generating,
231281 // so users can't fire a second message mid-reply.
232282 enabled = !view.loading && !view.generating,
233283 generating = view.generating,
234− onStop = { sessionId?.let { core.update(Event.LoadMessages(it)) } },
284+ onStop = { core.update(Event.CancelGeneration) },
235285 stopContentDescription = stringResource(R.string.chat_cd_stop),
236286 placeholder = stringResource(R.string.chat_input_placeholder),
237287 sendContentDescription = stringResource(R.string.chat_cd_send),
@@ -259,6 +309,14 @@ fun ChatScreen(core: Core) {
259309 reconnectLabel = stringResource(R.string.sse_reconnect),
260310 dismissLabel = stringResource(R.string.sse_dismiss),
261311 )
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+ )
262320 }
263321
264322 when {
@@ -366,12 +424,13 @@ private fun ChatScreenPreview() {
366424 title = "Refactor the parser",
367425 sessionId = "abc12345",
368426 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),
370428 MessageView(
371429 "a1",
372430 "assistant",
373431 "Sure — here's a **plan**:\n\n1. Extract helpers\n2. Add tests",
374432 1_700_000_010uL,
433+ MessageStatus.SENT,
375434 ),
376435 ),
377436 generating = false,
@@ -387,7 +446,7 @@ private fun ChatScreenGeneratingPreview() {
387446 PreviewChatScreen(
388447 title = "New session",
389448 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)),
391450 generating = true,
392451 )
393452 }
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+4−3
@@ -38,6 +38,7 @@ import androidx.compose.runtime.collectAsState
3838import androidx.compose.runtime.getValue
3939import androidx.compose.runtime.mutableStateOf
4040import androidx.compose.runtime.remember
41+import androidx.compose.runtime.saveable.rememberSaveable
4142import androidx.compose.runtime.setValue
4243import androidx.compose.ui.Alignment
4344import androidx.compose.ui.Modifier
@@ -71,9 +72,9 @@ fun ConnectScreen(core: Core) {
7172 var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
7273 var username by remember(view.username) { mutableStateOf(view.username) }
7374 var password by remember(view.password) { mutableStateOf(view.password) }
74− var passwordVisible by remember { mutableStateOf(false) }
75− var showCrashLogs by remember { mutableStateOf(false) }
76− var urlError by remember { mutableStateOf(false) }
75+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
76+ var showCrashLogs by rememberSaveable { mutableStateOf(false) }
77+ var urlError by rememberSaveable { mutableStateOf(false) }
7778 val errorHostState = ErrorHost(
7879 core = core,
7980 dismissLabel = stringResource(R.string.action_dismiss),
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+105−11
@@ -1,7 +1,10 @@
11package soy.iko.opencode.ui.screens
22
3+import androidx.compose.foundation.ExperimentalFoundationApi
34import androidx.compose.foundation.clickable
5+import androidx.compose.foundation.combinedClickable
46import androidx.compose.foundation.layout.Box
7+import androidx.compose.foundation.layout.PaddingValues
58import androidx.compose.foundation.layout.fillMaxSize
69import androidx.compose.foundation.layout.padding
710import androidx.compose.foundation.lazy.LazyColumn
@@ -11,6 +14,8 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
1114import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
1215import androidx.compose.material.icons.filled.Add
1316import androidx.compose.material.icons.filled.ChatBubble
17+import androidx.compose.material.icons.filled.Delete
18+import androidx.compose.material3.AlertDialog
1419import androidx.compose.material3.ExperimentalMaterial3Api
1520import androidx.compose.material3.Icon
1621import androidx.compose.material3.IconButton
@@ -18,17 +23,22 @@ import androidx.compose.material3.ListItem
1823import androidx.compose.material3.MaterialTheme
1924import androidx.compose.material3.Scaffold
2025import androidx.compose.material3.Text
26+import androidx.compose.material3.TextButton
2127import androidx.compose.material3.TopAppBar
2228import androidx.compose.material3.pulltorefresh.PullToRefreshBox
2329import androidx.compose.runtime.Composable
2430import androidx.compose.runtime.collectAsState
2531import androidx.compose.runtime.getValue
32+import androidx.compose.runtime.mutableStateOf
33+import androidx.compose.runtime.remember
34+import androidx.compose.runtime.setValue
2635import androidx.compose.ui.Modifier
2736import androidx.compose.ui.res.stringResource
2837import androidx.compose.ui.semantics.Role
2938import androidx.compose.ui.semantics.contentDescription
3039import androidx.compose.ui.semantics.role
3140import androidx.compose.ui.semantics.semantics
41+import androidx.compose.ui.text.style.TextOverflow
3242import androidx.compose.ui.tooling.preview.Preview
3343import androidx.compose.ui.unit.dp
3444import soy.iko.opencode.R
@@ -56,16 +66,26 @@ fun SessionsScreen(core: Core) {
5666 core = core,
5767 successConnectedLabel = stringResource(R.string.connect_success),
5868 successSessionCreatedLabel = stringResource(R.string.session_created),
69+ copiedLabel = stringResource(R.string.chat_copied),
70+ sessionDeletedLabel = stringResource(R.string.session_deleted),
5971 )
6072 val untitledLabel = stringResource(R.string.sessions_untitled)
6173 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) }
6282
6383 Scaffold(
6484 topBar = {
6585 TopAppBar(
6686 title = { Text(stringResource(R.string.top_bar_sessions)) },
6787 navigationIcon = {
68− IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
88+ IconButton(onClick = { showDisconnectConfirm = true }) {
6989 Icon(
7090 Icons.AutoMirrored.Filled.ArrowBack,
7191 contentDescription = stringResource(R.string.sessions_cd_back),
@@ -102,13 +122,17 @@ fun SessionsScreen(core: Core) {
102122 onRefresh = { core.update(Event.LoadSessions) },
103123 modifier = Modifier.fillMaxSize(),
104124 ) {
105− LazyColumn {
125+ LazyColumn(
126+ contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
127+ ) {
106128 items(view.sessions, key = { it.id }) { session ->
107129 SessionRow(
108130 session = session,
109131 untitledLabel = untitledLabel,
110132 idShortLabel = idShortTemplate.format(session.id.take(8)),
133+ deleteCdLabel = deleteCdLabel,
111134 onClick = { core.update(Event.SelectSession(session.id)) },
135+ onDelete = { pendingDelete = session },
112136 )
113137 }
114138 }
@@ -117,19 +141,80 @@ fun SessionsScreen(core: Core) {
117141 }
118142 }
119143 }
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+ }
120191}
121192
193+@OptIn(ExperimentalFoundationApi::class)
122194@Composable
123195private fun SessionRow(
124196 session: SessionView,
125197 untitledLabel: String,
126198 idShortLabel: String,
199+ deleteCdLabel: String,
127200 onClick: () -> Unit,
201+ onDelete: () -> Unit,
128202) {
129203 val title = session.title.ifEmpty { untitledLabel }
130204 ListItem(
131− headlineContent = { Text(title) },
132− supportingContent = { Text(idShortLabel) },
205+ headlineContent = {
206+ Text(
207+ text = title,
208+ maxLines = 1,
209+ overflow = TextOverflow.Ellipsis,
210+ )
211+ },
212+ supportingContent = {
213+ Text(
214+ text = idShortLabel,
215+ maxLines = 1,
216+ )
217+ },
133218 leadingContent = {
134219 Icon(
135220 Icons.Default.ChatBubble,
@@ -138,14 +223,19 @@ private fun SessionRow(
138223 )
139224 },
140225 trailingContent = {
141− Icon(
142− Icons.AutoMirrored.Filled.KeyboardArrowRight,
143− contentDescription = null,
144− tint = MaterialTheme.colorScheme.onSurfaceVariant,
145− )
226+ IconButton(onClick = onDelete) {
227+ Icon(
228+ Icons.Default.Delete,
229+ contentDescription = deleteCdLabel,
230+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
231+ )
232+ }
146233 },
147234 modifier = Modifier
148− .clickable(onClick = onClick)
235+ .combinedClickable(
236+ onClick = onClick,
237+ onLongClick = onDelete,
238+ )
149239 .semantics(mergeDescendants = true) {
150240 // Merge the row into a single TalkBack utterance and announce
151241 // it as a button so users know it's actionable.
@@ -177,7 +267,9 @@ private fun SessionsScreenPreview() {
177267 )
178268 }) { padding ->
179269 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
180− LazyColumn {
270+ LazyColumn(
271+ contentPadding = PaddingValues(vertical = 12.dp),
272+ ) {
181273 items(
182274 listOf(
183275 SessionView("abc12345def", "Refactor the parser"),
@@ -189,7 +281,9 @@ private fun SessionsScreenPreview() {
189281 session,
190282 untitledLabel = "Untitled",
191283 idShortLabel = "Session ${session.id.take(8)}",
284+ deleteCdLabel = "Delete session",
192285 onClick = {},
286+ onDelete = {},
193287 )
194288 }
195289 }
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+2−2
@@ -19,7 +19,7 @@ private val DarkColorScheme = darkColorScheme(
1919 surface = Color(0xFF1E1E1E),
2020 onPrimary = Color.Black,
2121 onSecondary = Color.Black,
22− onTertiary = Color.Black,
22+ onTertiary = Color(0xFF1A1A1A),
2323 onBackground = Color.White,
2424 onSurface = Color.White,
2525)
@@ -27,7 +27,7 @@ private val DarkColorScheme = darkColorScheme(
2727private val LightColorScheme = lightColorScheme(
2828 primary = Color(0xFF1565C0),
2929 secondary = Color(0xFF00897B),
30− tertiary = Color(0xFFF57C00),
30+ tertiary = Color(0xFFE65100),
3131 background = Color(0xFFF5F5F5),
3232 surface = Color.White,
3333 onPrimary = Color.White,
app/src/main/res/values/strings.xml+31
@@ -68,5 +68,36 @@
6868 <string name="sse_disconnected">Live stream disconnected</string>
6969 <string name="sse_reconnect">Reconnect</string>
7070 <string name="sse_dismiss">Dismiss</string>
71+ <string name="sse_connecting">Connecting live stream…</string>
72+ <string name="sse_live">Live</string>
7173 <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>
72103</resources>
app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.kt+55
@@ -226,4 +226,59 @@ class PureCoreFfiTest {
226226 fun `extractJsonNumber returns null for missing key`() {
227227 assertNull(extractJsonNumber("""{"a":"b"}""", "created"))
228228 }
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+ }
229284}
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 {
387387 }
388388 }
389389
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+
390462 companion object {
391463 @Throws(DeserializationError::class)
392464 fun deserialize(deserializer: Deserializer): Event {
@@ -408,6 +480,10 @@ sealed interface Event {
408480 13 -> NavigateToSessions.deserialize(deserializer)
409481 14 -> NavigateToConnect.deserialize(deserializer)
410482 15 -> DismissError.deserialize(deserializer)
483+ 16 -> DraftChanged.deserialize(deserializer)
484+ 17 -> CancelGeneration.deserialize(deserializer)
485+ 18 -> DeleteSession.deserialize(deserializer)
486+ 19 -> RetryMessage.deserialize(deserializer)
411487 else -> throw DeserializationError("Unknown variant index for Event: $index")
412488 }
413489 }
@@ -794,11 +870,58 @@ sealed interface HttpResult {
794870 }
795871}
796872
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+
797919data class MessageView(
798920 val id: String,
799921 val role: String,
800922 val text: String,
801923 val time: ULong,
924+ val status: soy.iko.opencode.core.MessageStatus,
802925) {
803926 fun serialize(serializer: Serializer) {
804927 serializer.increase_container_depth()
@@ -806,6 +929,7 @@ data class MessageView(
806929 serializer.serialize_str(role)
807930 serializer.serialize_str(text)
808931 serializer.serialize_u64(time)
932+ status.serialize(serializer)
809933 serializer.decrease_container_depth()
810934 }
811935
@@ -822,8 +946,9 @@ data class MessageView(
822946 val role = deserializer.deserialize_str()
823947 val text = deserializer.deserialize_str()
824948 val time = deserializer.deserialize_u64()
949+ val status = soy.iko.opencode.core.MessageStatus.deserialize(deserializer)
825950 deserializer.decrease_container_depth()
826− return MessageView(id, role, text, time)
951+ return MessageView(id, role, text, time, status)
827952 }
828953
829954 @Throws(DeserializationError::class)
shared/src/app.rs+143−2
@@ -47,6 +47,16 @@ pub enum Event {
4747 // Errors
4848 DismissError,
4949
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+
5060 // ── Internal events (skipped from typegen, sent only via then_send).
5161 // These occupy higher discriminant indices that are never
5262 // produced by the shell, so the index gap is harmless. ─────────
@@ -69,6 +79,9 @@ pub enum Event {
6979 #[serde(skip)]
7080 #[facet(skip)]
7181 HealthProbed(#[facet(opaque)] HttpResult<String>),
82+ #[serde(skip)]
83+ #[facet(skip)]
84+ SessionDeleted(#[facet(opaque)] HttpResult<String>),
7285}
7386
7487// ─── Effects ───────────────────────────────────────────────────────────────
@@ -117,12 +130,22 @@ pub struct SessionSummary {
117130 pub title: String,
118131}
119132
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+
120142#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
121143pub struct MessageView {
122144 pub id: String,
123145 pub role: String,
124146 pub text: String,
125147 pub time: u64,
148+ pub status: MessageStatus,
126149}
127150
128151// ─── ViewModel ─────────────────────────────────────────────────────────────
@@ -186,6 +209,13 @@ impl OpencodeApp {
186209 self.with_auth(req, model)
187210 }
188211
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+
189219 /// Attach the Basic-Auth `Authorization` header if the model carries
190220 /// credentials. Uses RFC 7617 `Basic` scheme with base64-encoded
191221 /// `username:password`.
@@ -442,8 +472,58 @@ impl App for OpencodeApp {
442472 render()
443473 }
444474
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+
445512 // ── Internal events (not type-generated, sent via then_send) ───
446513 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+ }
447527 }
448528 }
449529
@@ -607,7 +687,7 @@ fn parse_messages(body: &str) -> Vec<MessageView> {
607687 let time = extract_number(obj, "created").unwrap_or(0);
608688 if !id.is_empty() {
609689 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 });
611691 }
612692 }
613693 }
@@ -739,6 +819,7 @@ mod test {
739819 role: "user".to_string(),
740820 text: "hello".to_string(),
741821 time: 0,
822+ status: MessageStatus::Sent,
742823 });
743824 app.update(Event::NavigateToSessions, &mut model);
744825 assert!(model.current_session_id.is_none());
@@ -791,6 +872,61 @@ mod test {
791872 assert!(model.error.is_some());
792873 }
793874
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+
794930 #[test]
795931 fn auth_header_built_from_credentials() {
796932 let app = OpencodeApp;
@@ -829,7 +965,8 @@ mod test {
829965 // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
830966 // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
831967 // 12=NavigateToChat, 13=NavigateToSessions, 14=NavigateToConnect,
832− // 15=DismissError
968+ // 15=DismissError, 16=DraftChanged, 17=CancelGeneration,
969+ // 18=DeleteSession, 19=RetryMessage
833970 //
834971 // Serialize each Rust variant and check the leading u32 index.
835972 let cases: &[(Event, u32)] = &[
@@ -849,6 +986,10 @@ mod test {
849986 (Event::NavigateToSessions, 13),
850987 (Event::NavigateToConnect, 14),
851988 (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),
852993 ];
853994 for (event, expected) in cases {
854995 let bytes = bincode::serialize(event).unwrap();