git.iko.soy/opencode-android
on master help sign in
Improve UI/UX with markdown rendering, error surfacing, back navigation, persisted server URL, generating indicator, session titles, timestamps, pull-to-refresh, crash log viewer, and crossfade transitions
e7dfe20iko
app/build.gradle.kts+4−2
@@ -9,8 +9,8 @@ plugins {
9
9
10android {
10android {
11 namespace = "soy.iko.opencode"
11 namespace = "soy.iko.opencode"
12− compileSdk = 36
12+ compileSdk = 37
13− buildToolsVersion = "36.0.0"
13+ buildToolsVersion = "37.0.0"
14
14
15 defaultConfig {
15 defaultConfig {
16 applicationId = "soy.iko.opencode"
16 applicationId = "soy.iko.opencode"
@@ -77,6 +77,8 @@ dependencies {
77 implementation(libs.androidx.navigation.compose)
77 implementation(libs.androidx.navigation.compose)
78
78
79 implementation(libs.kotlinx.coroutines.android)
79 implementation(libs.kotlinx.coroutines.android)
80+ implementation(libs.markdown.renderer.core)
81+ implementation(libs.markdown.renderer.m3)
80
82
81 implementation(libs.ktor.client.core)
83 implementation(libs.ktor.client.core)
82 implementation(libs.ktor.client.okhttp)
84 implementation(libs.ktor.client.okhttp)
app/src/main/java/soy/iko/opencode/MainActivity.kt+27−4
@@ -4,11 +4,17 @@ import android.os.Bundle
4import androidx.activity.ComponentActivity
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
5import androidx.activity.compose.setContent
6import androidx.activity.enableEdgeToEdge
6import androidx.activity.enableEdgeToEdge
7+import androidx.activity.compose.BackHandler
8+import androidx.compose.animation.AnimatedContent
9+import androidx.compose.animation.togetherWith
10+import androidx.compose.animation.fadeIn
11+import androidx.compose.animation.fadeOut
7import androidx.compose.runtime.Composable
12import androidx.compose.runtime.Composable
8import androidx.compose.runtime.collectAsState
13import androidx.compose.runtime.collectAsState
9import androidx.compose.runtime.getValue
14import androidx.compose.runtime.getValue
10import androidx.lifecycle.viewmodel.compose.viewModel
15import androidx.lifecycle.viewmodel.compose.viewModel
11import soy.iko.opencode.core.Core
16import soy.iko.opencode.core.Core
17+import soy.iko.opencode.core.Event
12import soy.iko.opencode.core.Screen
18import soy.iko.opencode.core.Screen
13import soy.iko.opencode.ui.screens.ChatScreen
19import soy.iko.opencode.ui.screens.ChatScreen
14import soy.iko.opencode.ui.screens.ConnectScreen
20import soy.iko.opencode.ui.screens.ConnectScreen
@@ -31,9 +37,26 @@ class MainActivity : ComponentActivity() {
31fun App(core: Core = viewModel()) {
37fun App(core: Core = viewModel()) {
32 val view by core.view.collectAsState()
38 val view by core.view.collectAsState()
33
39
40+ // Map the system back button to logical in-app navigation so it never
41+ // silently exits the app from a sub-screen.
42+ BackHandler(enabled = view.screen != Screen.CONNECT) {
34− when (view.screen) {
43+ when (view.screen) {
44+ Screen.CHAT -> core.update(Event.NavigateToSessions)
45+ Screen.SESSIONS -> core.update(Event.NavigateToConnect)
46+ Screen.CONNECT -> { /* no-op; CONNECT is the root */ }
47+ }
48+ }
49+
50+ // Crossfade between screens for a smoother navigation feel.
51+ AnimatedContent(
52+ targetState = view.screen,
53+ transitionSpec = { fadeIn() togetherWith fadeOut() },
54+ label = "screen-transition",
55+ ) { screen ->
56+ when (screen) {
35− Screen.CONNECT -> ConnectScreen(core)
57+ Screen.CONNECT -> ConnectScreen(core)
36− Screen.SESSIONS -> SessionsScreen(core)
58+ Screen.SESSIONS -> SessionsScreen(core)
37− Screen.CHAT -> ChatScreen(core)
59+ Screen.CHAT -> ChatScreen(core)
60+ }
38 }
61 }
39}
62}
app/src/main/java/soy/iko/opencode/core/Core.kt+29−4
@@ -1,5 +1,7 @@
1package soy.iko.opencode.core
1package soy.iko.opencode.core
2
2
3+import android.app.Application
4+import androidx.lifecycle.AndroidViewModel
3import androidx.lifecycle.viewModelScope
5import androidx.lifecycle.viewModelScope
4import kotlinx.coroutines.flow.MutableStateFlow
6import kotlinx.coroutines.flow.MutableStateFlow
5import kotlinx.coroutines.flow.StateFlow
7import kotlinx.coroutines.flow.StateFlow
@@ -23,15 +25,23 @@ import soy.iko.opencode.CrashLogger
23 * Crash logs from [CrashLogger] are forwarded to the core as
25 * Crash logs from [CrashLogger] are forwarded to the core as
24 * [Event.CrashLog] events so they appear in the ViewModel.
26 * [Event.CrashLog] events so they appear in the ViewModel.
25 */
27 */
26−class Core : androidx.lifecycle.ViewModel() {
28+class Core(application: Application) : AndroidViewModel(application) {
27 private val ffi: CoreFfi = CoreFfi.create()
29 private val ffi: CoreFfi = CoreFfi.create()
28 private val httpClient = HttpClient()
30 private val httpClient = HttpClient()
29 private val sseClient = SseClient()
31 private val sseClient = SseClient()
30
32
33+ private val prefs = application.getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE)
34+
31 private val _view = MutableStateFlow(getInitialView())
35 private val _view = MutableStateFlow(getInitialView())
32 val view: StateFlow<ViewModel> = _view.asStateFlow()
36 val view: StateFlow<ViewModel> = _view.asStateFlow()
33
37
34 init {
38 init {
39+ // Restore the persisted server URL before the first connect so the
40+ // user doesn't have to re-enter it on every launch.
41+ val savedUrl = prefs.getString(KEY_SERVER_URL, null)
42+ if (savedUrl != null) {
43+ update(Event.ServerUrlChanged(savedUrl))
44+ }
35 update(Event.Start)
45 update(Event.Start)
36
46
37 viewModelScope.launch {
47 viewModelScope.launch {
@@ -54,7 +64,7 @@ class Core : androidx.lifecycle.ViewModel() {
54 private fun processEffect(request: Request) {
64 private fun processEffect(request: Request) {
55 when (val effect = request.effect) {
65 when (val effect = request.effect) {
56 is Effect.Render -> {
66 is Effect.Render -> {
57− _view.value = ViewModel.bincodeDeserialize(ffi.view())
67+ refreshView()
58 }
68 }
59 is Effect.Http -> {
69 is Effect.Http -> {
60 handleHttpEffect(effect.value, request.id)
70 handleHttpEffect(effect.value, request.id)
@@ -70,7 +80,7 @@ class Core : androidx.lifecycle.ViewModel() {
70 for (req in requests) {
80 for (req in requests) {
71 processEffect(req)
81 processEffect(req)
72 }
82 }
73− _view.value = ViewModel.bincodeDeserialize(ffi.view())
83+ refreshView()
74
84
75 val currentView = _view.value
85 val currentView = _view.value
76 if (currentView.connected && currentView.currentSessionId != null) {
86 if (currentView.connected && currentView.currentSessionId != null) {
@@ -79,6 +89,13 @@ class Core : androidx.lifecycle.ViewModel() {
79 }
89 }
80 }
90 }
81
91
92+ private fun refreshView() {
93+ val newView = ViewModel.bincodeDeserialize(ffi.view())
94+ _view.value = newView
95+ // Persist the server URL whenever it changes so it survives relaunch.
96+ prefs.edit().putString(KEY_SERVER_URL, newView.serverUrl).apply()
97+ }
98+
82 /**
99 /**
83 * Forward any persisted crash reports to the core so they appear in the
100 * Forward any persisted crash reports to the core so they appear in the
84 * ViewModel's crash-log summary. This runs once on init.
101 * ViewModel's crash-log summary. This runs once on init.
@@ -99,9 +116,10 @@ class Core : androidx.lifecycle.ViewModel() {
99 return try {
116 return try {
100 ViewModel.bincodeDeserialize(ffi.view())
117 ViewModel.bincodeDeserialize(ffi.view())
101 } catch (e: Throwable) {
118 } catch (e: Throwable) {
119+ val defaultUrl = prefs.getString(KEY_SERVER_URL, "http://localhost:4096") ?: "http://localhost:4096"
102 ViewModel(
120 ViewModel(
103 screen = Screen.CONNECT,
121 screen = Screen.CONNECT,
104− serverUrl = "http://localhost:4096",
122+ serverUrl = defaultUrl,
105 username = "",
123 username = "",
106 password = "",
124 password = "",
107 authRequired = false,
125 authRequired = false,
@@ -110,8 +128,10 @@ class Core : androidx.lifecycle.ViewModel() {
110 error = null,
128 error = null,
111 sessions = emptyList(),
129 sessions = emptyList(),
112 currentSessionId = null,
130 currentSessionId = null,
131+ currentSessionTitle = "",
113 messages = emptyList(),
132 messages = emptyList(),
114 draftMessage = "",
133 draftMessage = "",
134+ generating = false,
115 crashLogCount = 0u,
135 crashLogCount = 0u,
116 latestCrashLog = null,
136 latestCrashLog = null,
117 )
137 )
@@ -121,4 +141,9 @@ class Core : androidx.lifecycle.ViewModel() {
121 override fun onCleared() {
141 override fun onCleared() {
122 sseClient.disconnect()
142 sseClient.disconnect()
123 }
143 }
144+
145+ companion object {
146+ private const val PREFS_NAME = "opencode_prefs"
147+ private const val KEY_SERVER_URL = "server_url"
148+ }
124}
149}
app/src/main/java/soy/iko/opencode/core/PureCoreFfi.kt+26−1
@@ -45,8 +45,10 @@ internal data class PureModel(
45 var error: String? = null,
45 var error: String? = null,
46 var sessions: MutableList<PureSession> = mutableListOf(),
46 var sessions: MutableList<PureSession> = mutableListOf(),
47 var currentSessionId: String? = null,
47 var currentSessionId: String? = null,
48+ var currentSessionTitle: String = "",
48 var messages: MutableList<PureMessage> = mutableListOf(),
49 var messages: MutableList<PureMessage> = mutableListOf(),
49 var draftMessage: String = "",
50 var draftMessage: String = "",
51+ var generating: Boolean = false,
50 var crashLogs: MutableList<String> = mutableListOf(),
52 var crashLogs: MutableList<String> = mutableListOf(),
51 var pendingRequests: MutableMap<UInt, PendingRequest> = mutableMapOf(),
53 var pendingRequests: MutableMap<UInt, PendingRequest> = mutableMapOf(),
52 var nextId: UInt = 0u,
54 var nextId: UInt = 0u,
@@ -55,7 +57,7 @@ internal data class PureModel(
55 val requests = mutableListOf<Request>()
57 val requests = mutableListOf<Request>()
56 when (event) {
58 when (event) {
57 is Event.Start -> {
59 is Event.Start -> {
58− serverUrl = "http://localhost:4096"
60+ // Don't reset serverUrl — the shell restores the persisted value.
59 }
61 }
60 is Event.ServerUrlChanged -> {
62 is Event.ServerUrlChanged -> {
61 serverUrl = event.value
63 serverUrl = event.value
@@ -87,6 +89,7 @@ internal data class PureModel(
87 }
89 }
88 is Event.SelectSession -> {
90 is Event.SelectSession -> {
89 currentSessionId = event.value
91 currentSessionId = event.value
92+ currentSessionTitle = sessions.firstOrNull { it.id == event.value }?.title ?: ""
90 messages.clear()
93 messages.clear()
91 val id = nextId++
94 val id = nextId++
92 pendingRequests[id] = PendingRequest.LoadMessages(event.value)
95 pendingRequests[id] = PendingRequest.LoadMessages(event.value)
@@ -104,6 +107,7 @@ internal data class PureModel(
104 }
107 }
105 is Event.LoadMessages -> {
108 is Event.LoadMessages -> {
106 loading = true
109 loading = true
110+ generating = false
107 val id = nextId++
111 val id = nextId++
108 pendingRequests[id] = PendingRequest.LoadMessages(event.value)
112 pendingRequests[id] = PendingRequest.LoadMessages(event.value)
109 requests.add(
113 requests.add(
@@ -113,6 +117,7 @@ internal data class PureModel(
113 is Event.SendMessage -> {
117 is Event.SendMessage -> {
114 val sessionId = currentSessionId ?: return Requests(requests)
118 val sessionId = currentSessionId ?: return Requests(requests)
115 draftMessage = ""
119 draftMessage = ""
120+ generating = true
116 val body = """{"sessionID":"$sessionId","parts":[{"type":"text","text":"${event.value}"}]}"""
121 val body = """{"sessionID":"$sessionId","parts":[{"type":"text","text":"${event.value}"}]}"""
117 .toByteArray()
122 .toByteArray()
118 val id = nextId++
123 val id = nextId++
@@ -139,11 +144,23 @@ internal data class PureModel(
139 }
144 }
140 is Event.NavigateToChat -> {
145 is Event.NavigateToChat -> {
141 currentSessionId = event.value
146 currentSessionId = event.value
147+ currentSessionTitle = sessions.firstOrNull { it.id == event.value }?.title ?: ""
142 messages.clear()
148 messages.clear()
143 }
149 }
144 is Event.NavigateToSessions -> {
150 is Event.NavigateToSessions -> {
145 currentSessionId = null
151 currentSessionId = null
152+ currentSessionTitle = ""
146 messages.clear()
153 messages.clear()
154+ generating = false
155+ }
156+ is Event.NavigateToConnect -> {
157+ currentSessionId = null
158+ currentSessionTitle = ""
159+ messages.clear()
160+ generating = false
161+ connected = false
162+ authRequired = false
163+ loading = false
147 }
164 }
148 is Event.DismissError -> {
165 is Event.DismissError -> {
149 error = null
166 error = null
@@ -210,7 +227,11 @@ internal data class PureModel(
210 val body = result.value.body.content.decodeToString()
227 val body = result.value.body.content.decodeToString()
211 val sid = extractJsonString(body, "id") ?: ""
228 val sid = extractJsonString(body, "id") ?: ""
212 if (sid.isNotEmpty()) {
229 if (sid.isNotEmpty()) {
230+ val title = extractJsonString(body, "title")?.takeIf { it.isNotEmpty() }
231+ ?: "New session"
213 currentSessionId = sid
232 currentSessionId = sid
233+ currentSessionTitle = title
234+ sessions.add(0, PureSession(sid, title))
214 messages.clear()
235 messages.clear()
215 val newId = nextId++
236 val newId = nextId++
216 pendingRequests[newId] = PendingRequest.LoadMessages(sid)
237 pendingRequests[newId] = PendingRequest.LoadMessages(sid)
@@ -226,6 +247,7 @@ internal data class PureModel(
226 }
247 }
227 is PendingRequest.LoadMessages -> {
248 is PendingRequest.LoadMessages -> {
228 loading = false
249 loading = false
250+ generating = false
229 when (result) {
251 when (result) {
230 is HttpResult.Ok -> {
252 is HttpResult.Ok -> {
231 val body = result.value.body.content.decodeToString()
253 val body = result.value.body.content.decodeToString()
@@ -247,6 +269,7 @@ internal data class PureModel(
247 )
269 )
248 }
270 }
249 is HttpResult.Err -> {
271 is HttpResult.Err -> {
272+ generating = false
250 error = "Failed to send message: ${result.value}"
273 error = "Failed to send message: ${result.value}"
251 }
274 }
252 }
275 }
@@ -272,8 +295,10 @@ internal data class PureModel(
272 error = error,
295 error = error,
273 sessions = sessions.map { SessionView(it.id, it.title) },
296 sessions = sessions.map { SessionView(it.id, it.title) },
274 currentSessionId = currentSessionId,
297 currentSessionId = currentSessionId,
298+ currentSessionTitle = currentSessionTitle,
275 messages = messages.map { MessageView(it.id, it.role, it.text, it.time) },
299 messages = messages.map { MessageView(it.id, it.role, it.text, it.time) },
276 draftMessage = draftMessage,
300 draftMessage = draftMessage,
301+ generating = generating,
277 crashLogCount = crashLogs.size.toUInt(),
302 crashLogCount = crashLogs.size.toUInt(),
278 latestCrashLog = crashLogs.lastOrNull(),
303 latestCrashLog = crashLogs.lastOrNull(),
279 )
304 )
app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.ktnew file+56
@@ -0,0 +1,56 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.material3.MaterialTheme
4+import androidx.compose.material3.Snackbar
5+import androidx.compose.material3.SnackbarDuration
6+import androidx.compose.material3.SnackbarHost
7+import androidx.compose.material3.SnackbarHostState
8+import androidx.compose.material3.SnackbarResult
9+import androidx.compose.runtime.Composable
10+import androidx.compose.runtime.LaunchedEffect
11+import androidx.compose.runtime.collectAsState
12+import androidx.compose.runtime.getValue
13+import androidx.compose.runtime.remember
14+import soy.iko.opencode.core.Core
15+import soy.iko.opencode.core.Event
16+
17+/**
18+ * A [SnackbarHostState] that observes [ViewModel.error] and surfaces it to
19+ * the user. Offers a "Dismiss" action that sends [Event.DismissError] to
20+ * clear the error in the core so it doesn't re-show on recomposition.
21+ *
22+ * Used by every screen so error messages are never silently dropped.
23+ */
24+@Composable
25+fun ErrorHost(core: Core): SnackbarHostState {
26+ val view by core.view.collectAsState()
27+ val snackbarHostState = remember { SnackbarHostState() }
28+
29+ LaunchedEffect(view.error) {
30+ val message = view.error
31+ if (!message.isNullOrEmpty()) {
32+ val result = snackbarHostState.showSnackbar(
33+ message = message,
34+ actionLabel = "Dismiss",
35+ duration = SnackbarDuration.Long,
36+ )
37+ if (result == SnackbarResult.ActionPerformed) {
38+ core.update(Event.DismissError)
39+ }
40+ }
41+ }
42+
43+ return snackbarHostState
44+}
45+
46+@Composable
47+fun ErrorSnackbarHost(snackbarHostState: SnackbarHostState) {
48+ SnackbarHost(snackbarHostState) { data ->
49+ Snackbar(
50+ containerColor = MaterialTheme.colorScheme.errorContainer,
51+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
52+ actionContentColor = MaterialTheme.colorScheme.onErrorContainer,
53+ snackbarData = data,
54+ )
55+ }
56+}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+130−19
@@ -1,8 +1,10 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
2
2
3+import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.PaddingValues
6import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxSize
@@ -31,16 +33,26 @@ import androidx.compose.material3.TopAppBar
31import androidx.compose.runtime.Composable
33import androidx.compose.runtime.Composable
32import androidx.compose.runtime.LaunchedEffect
34import androidx.compose.runtime.LaunchedEffect
33import androidx.compose.runtime.collectAsState
35import androidx.compose.runtime.collectAsState
36+import androidx.compose.runtime.derivedStateOf
34import androidx.compose.runtime.getValue
37import androidx.compose.runtime.getValue
35import androidx.compose.runtime.mutableStateOf
38import androidx.compose.runtime.mutableStateOf
36import androidx.compose.runtime.remember
39import androidx.compose.runtime.remember
37import androidx.compose.runtime.setValue
40import androidx.compose.runtime.setValue
38import androidx.compose.ui.Alignment
41import androidx.compose.ui.Alignment
39import androidx.compose.ui.Modifier
42import androidx.compose.ui.Modifier
40−import androidx.compose.ui.draw.clip
43+import androidx.compose.ui.platform.LocalConfiguration
44+import androidx.compose.ui.text.font.FontStyle
45+import androidx.compose.ui.text.style.TextOverflow
41import androidx.compose.ui.unit.dp
46import androidx.compose.ui.unit.dp
47+import com.mikepenz.markdown.m3.Markdown
42import soy.iko.opencode.core.Core
48import soy.iko.opencode.core.Core
43import soy.iko.opencode.core.Event
49import soy.iko.opencode.core.Event
50+import soy.iko.opencode.core.MessageView
51+import soy.iko.opencode.ui.components.ErrorHost
52+import soy.iko.opencode.ui.components.ErrorSnackbarHost
53+import java.text.SimpleDateFormat
54+import java.util.Date
55+import java.util.Locale
44
56
45@OptIn(ExperimentalMaterial3Api::class)
57@OptIn(ExperimentalMaterial3Api::class)
46@Composable
58@Composable
@@ -49,8 +61,18 @@ fun ChatScreen(core: Core) {
49 var inputText by remember { mutableStateOf("") }
61 var inputText by remember { mutableStateOf("") }
50 val listState = rememberLazyListState()
62 val listState = rememberLazyListState()
51
63
64+ // Track whether the user is parked at the bottom of the list. We only
65+ // auto-scroll when they are, so reading history isn't yanked away as
66+ // new messages stream in.
67+ val isAtBottom by remember {
68+ derivedStateOf {
69+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
70+ lastVisible >= view.messages.size - 2
71+ }
72+ }
73+
52 LaunchedEffect(view.messages.size) {
74 LaunchedEffect(view.messages.size) {
53− if (view.messages.isNotEmpty()) {
75+ if (view.messages.isNotEmpty() && (isAtBottom || view.messages.size == 1)) {
54 listState.animateScrollToItem(view.messages.size - 1)
76 listState.animateScrollToItem(view.messages.size - 1)
55 }
77 }
56 }
78 }
@@ -59,10 +81,23 @@ fun ChatScreen(core: Core) {
59 topBar = {
81 topBar = {
60 TopAppBar(
82 TopAppBar(
61 title = {
83 title = {
84+ Column {
62− Text(
85+ Text(
86+ text = view.currentSessionTitle.ifEmpty {
63− view.currentSessionId?.take(8)?.plus("…") ?: "Chat",
87+ view.currentSessionId?.take(8)?.plus("…") ?: "Chat"
88+ },
89+ maxLines = 1,
90+ overflow = TextOverflow.Ellipsis,
91+ )
92+ if (view.currentSessionTitle.isNotEmpty() && view.currentSessionId != null) {
93+ Text(
94+ text = view.currentSessionId!!.take(8),
95+ style = MaterialTheme.typography.labelSmall,
96+ color = MaterialTheme.colorScheme.onSurfaceVariant,
64− maxLines = 1,
97+ maxLines = 1,
65− )
98+ )
99+ }
100+ }
66 },
101 },
67 navigationIcon = {
102 navigationIcon = {
68 IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
103 IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
@@ -84,6 +119,7 @@ fun ChatScreen(core: Core) {
84 enabled = !view.loading,
119 enabled = !view.loading,
85 )
120 )
86 },
121 },
122+ snackbarHost = { ErrorSnackbarHost(ErrorHost(core)) },
87 ) { padding ->
123 ) { padding ->
88 Box(
124 Box(
89 modifier = Modifier
125 modifier = Modifier
@@ -111,11 +147,18 @@ fun ChatScreen(core: Core) {
111 state = listState,
147 state = listState,
112 modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
148 modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
113 verticalArrangement = Arrangement.spacedBy(8.dp),
149 verticalArrangement = Arrangement.spacedBy(8.dp),
114− contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
150+ contentPadding = PaddingValues(vertical = 12.dp),
115 ) {
151 ) {
116 items(view.messages, key = { it.id }) { message ->
152 items(view.messages, key = { it.id }) { message ->
117 MessageBubble(message)
153 MessageBubble(message)
118 }
154 }
155+ // "Thinking" indicator shown after the user sends, while
156+ // the assistant reply hasn't arrived yet.
157+ item(key = "__generating__") {
158+ AnimatedVisibility(visible = view.generating) {
159+ GeneratingIndicator()
160+ }
161+ }
119 }
162 }
120 }
163 }
121 }
164 }
@@ -123,8 +166,9 @@ fun ChatScreen(core: Core) {
123}
166}
124
167
125@Composable
168@Composable
126−private fun MessageBubble(message: soy.iko.opencode.core.MessageView) {
169+private fun MessageBubble(message: MessageView) {
127 val isUser = message.role == "user"
170 val isUser = message.role == "user"
171+ val maxWidth = LocalConfiguration.current.screenWidthDp.dp * 0.82f
128 Row(
172 Row(
129 modifier = Modifier.fillMaxWidth(),
173 modifier = Modifier.fillMaxWidth(),
130 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
174 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
@@ -146,22 +190,86 @@ private fun MessageBubble(message: soy.iko.opencode.core.MessageView) {
146 bottomStart = if (isUser) 16.dp else 4.dp,
190 bottomStart = if (isUser) 16.dp else 4.dp,
147 bottomEnd = if (isUser) 4.dp else 16.dp,
191 bottomEnd = if (isUser) 4.dp else 16.dp,
148 ),
192 ),
149− modifier = Modifier.widthIn(max = 280.dp),
193+ modifier = Modifier.widthIn(max = maxWidth),
150 ) {
194 ) {
151 Column(
195 Column(
152 modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
196 modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
153 ) {
197 ) {
154 if (!isUser) {
198 if (!isUser) {
199+ Row(
200+ modifier = Modifier.fillMaxWidth(),
201+ horizontalArrangement = Arrangement.SpaceBetween,
202+ verticalAlignment = Alignment.CenterVertically,
203+ ) {
204+ Text(
205+ text = message.role.replaceFirstChar { it.uppercase() },
206+ style = MaterialTheme.typography.labelSmall,
207+ color = MaterialTheme.colorScheme.primary,
208+ )
209+ if (message.time > 0uL) {
210+ Text(
211+ text = formatTimestamp(message.time),
212+ style = MaterialTheme.typography.labelSmall,
213+ color = MaterialTheme.colorScheme.onSurfaceVariant,
214+ )
215+ }
216+ }
217+ Spacer(Modifier.height(2.dp))
218+ }
219+ if (message.text.isBlank()) {
155 Text(
220 Text(
156− text = message.role.replaceFirstChar { it.uppercase() },
221+ text = "(no content)",
222+ style = MaterialTheme.typography.bodyMedium,
223+ fontStyle = FontStyle.Italic,
224+ )
225+ } else if (isUser) {
226+ Text(
227+ text = message.text,
157− style = MaterialTheme.typography.labelSmall,
228+ style = MaterialTheme.typography.bodyMedium,
158− color = MaterialTheme.colorScheme.primary,
229+ )
230+ } else {
231+ // Assistant replies often contain markdown (code blocks,
232+ // lists, etc.) — render them properly.
233+ Markdown(
234+ content = message.text,
235+ modifier = Modifier.fillMaxWidth(),
159 )
236 )
160− Spacer(Modifier.height(2.dp))
161 }
237 }
238+ }
239+ }
240+ }
241+}
242+
243+@Composable
244+private fun GeneratingIndicator() {
245+ Row(
246+ horizontalArrangement = Arrangement.Start,
247+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
248+ ) {
249+ Surface(
250+ color = MaterialTheme.colorScheme.surfaceVariant,
251+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
252+ shape = RoundedCornerShape(
253+ topStart = 16.dp,
254+ topEnd = 16.dp,
255+ bottomStart = 4.dp,
256+ bottomEnd = 16.dp,
257+ ),
258+ modifier = Modifier.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.82f),
259+ ) {
260+ Row(
261+ verticalAlignment = Alignment.CenterVertically,
262+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
263+ ) {
264+ CircularProgressIndicator(
265+ modifier = Modifier.size(14.dp),
266+ strokeWidth = 2.dp,
267+ )
268+ Spacer(Modifier.size(8.dp))
162 Text(
269 Text(
163− text = message.text.ifBlank { "(no content)" },
270+ text = "Generating…",
164 style = MaterialTheme.typography.bodyMedium,
271 style = MaterialTheme.typography.bodyMedium,
272+ fontStyle = FontStyle.Italic,
165 )
273 )
166 }
274 }
167 }
275 }
@@ -193,6 +301,8 @@ private fun ChatInputBar(
193 maxLines = 4,
301 maxLines = 4,
194 )
302 )
195 Spacer(Modifier.size(8.dp))
303 Spacer(Modifier.size(8.dp))
304+ // Rely on the standard disabled styling instead of overriding
305+ // the tint manually.
196 IconButton(
306 IconButton(
197 onClick = onSend,
307 onClick = onSend,
198 enabled = enabled && text.isNotBlank(),
308 enabled = enabled && text.isNotBlank(),
@@ -200,13 +310,14 @@ private fun ChatInputBar(
200 Icon(
310 Icon(
201 Icons.AutoMirrored.Filled.Send,
311 Icons.AutoMirrored.Filled.Send,
202 contentDescription = "Send",
312 contentDescription = "Send",
203− tint = if (text.isNotBlank()) {
204− MaterialTheme.colorScheme.primary
205− } else {
206− MaterialTheme.colorScheme.onSurfaceVariant
207− },
208 )
313 )
209 }
314 }
210 }
315 }
211 }
316 }
212}
317}
318+
319+private fun formatTimestamp(epochSeconds: ULong): String {
320+ val date = Date(epochSeconds.toLong() * 1000)
321+ val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
322+ return formatter.format(date)
323+}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+127−27
@@ -3,30 +3,36 @@ package soy.iko.opencode.ui.screens
3import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.height
10import androidx.compose.foundation.layout.height
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.layout.size
13+import androidx.compose.foundation.rememberScrollState
14+import androidx.compose.foundation.text.KeyboardActions
12import androidx.compose.foundation.text.KeyboardOptions
15import androidx.compose.foundation.text.KeyboardOptions
16+import androidx.compose.foundation.verticalScroll
13import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.filled.Dns
18import androidx.compose.material.icons.filled.Dns
15import androidx.compose.material.icons.filled.Lock
19import androidx.compose.material.icons.filled.Lock
20+import androidx.compose.material.icons.filled.Visibility
21+import androidx.compose.material.icons.filled.VisibilityOff
22+import androidx.compose.material.icons.filled.Warning
23+import androidx.compose.material3.AlertDialog
16import androidx.compose.material3.Button
24import androidx.compose.material3.Button
17import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.CircularProgressIndicator
18import androidx.compose.material3.ExperimentalMaterial3Api
26import androidx.compose.material3.ExperimentalMaterial3Api
19import androidx.compose.material3.Icon
27import androidx.compose.material3.Icon
28+import androidx.compose.material3.IconButton
20import androidx.compose.material3.MaterialTheme
29import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.OutlinedTextField
30import androidx.compose.material3.OutlinedTextField
22import androidx.compose.material3.Scaffold
31import androidx.compose.material3.Scaffold
23−import androidx.compose.material3.SnackbarHost
24−import androidx.compose.material3.SnackbarHostState
25import androidx.compose.material3.Text
32import androidx.compose.material3.Text
26import androidx.compose.material3.TextButton
33import androidx.compose.material3.TextButton
27import androidx.compose.material3.TopAppBar
34import androidx.compose.material3.TopAppBar
28import androidx.compose.runtime.Composable
35import androidx.compose.runtime.Composable
29−import androidx.compose.runtime.LaunchedEffect
30import androidx.compose.runtime.collectAsState
36import androidx.compose.runtime.collectAsState
31import androidx.compose.runtime.getValue
37import androidx.compose.runtime.getValue
32import androidx.compose.runtime.mutableStateOf
38import androidx.compose.runtime.mutableStateOf
@@ -37,9 +43,14 @@ import androidx.compose.ui.Modifier
37import androidx.compose.ui.text.input.ImeAction
43import androidx.compose.ui.text.input.ImeAction
38import androidx.compose.ui.text.input.KeyboardType
44import androidx.compose.ui.text.input.KeyboardType
39import androidx.compose.ui.text.input.PasswordVisualTransformation
45import androidx.compose.ui.text.input.PasswordVisualTransformation
46+import androidx.compose.ui.text.input.VisualTransformation
40import androidx.compose.ui.unit.dp
47import androidx.compose.ui.unit.dp
48+import soy.iko.opencode.CrashLogger
41import soy.iko.opencode.core.Core
49import soy.iko.opencode.core.Core
42import soy.iko.opencode.core.Event
50import soy.iko.opencode.core.Event
51+import soy.iko.opencode.ui.components.ErrorHost
52+import soy.iko.opencode.ui.components.ErrorSnackbarHost
53+import java.io.File
43
54
44@OptIn(ExperimentalMaterial3Api::class)
55@OptIn(ExperimentalMaterial3Api::class)
45@Composable
56@Composable
@@ -48,19 +59,24 @@ fun ConnectScreen(core: Core) {
48 var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
59 var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
49 var username by remember(view.username) { mutableStateOf(view.username) }
60 var username by remember(view.username) { mutableStateOf(view.username) }
50 var password by remember(view.password) { mutableStateOf(view.password) }
61 var password by remember(view.password) { mutableStateOf(view.password) }
62+ var passwordVisible by remember { mutableStateOf(false) }
63+ var showCrashLogs by remember { mutableStateOf(false) }
51− val snackbarHostState = remember { SnackbarHostState() }
64+ val snackbarHostState = ErrorHost(core)
52
65
53− LaunchedEffect(view.error) {
66+ fun submit() {
54− view.error?.let {
67+ core.update(Event.ServerUrlChanged(url))
55− snackbarHostState.showSnackbar(it)
68+ if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
69+ core.update(Event.UsernameChanged(username))
70+ core.update(Event.PasswordChanged(password))
56 }
71 }
72+ core.update(Event.Connect)
57 }
73 }
58
74
59 Scaffold(
75 Scaffold(
60 topBar = {
76 topBar = {
61 TopAppBar(title = { Text("Opencode") })
77 TopAppBar(title = { Text("Opencode") })
62 },
78 },
63− snackbarHost = { SnackbarHost(snackbarHostState) },
79+ snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
64 ) { padding ->
80 ) { padding ->
65 Column(
81 Column(
66 horizontalAlignment = Alignment.CenterHorizontally,
82 horizontalAlignment = Alignment.CenterHorizontally,
@@ -68,7 +84,8 @@ fun ConnectScreen(core: Core) {
68 modifier = Modifier
84 modifier = Modifier
69 .fillMaxSize()
85 .fillMaxSize()
70 .padding(padding)
86 .padding(padding)
71− .padding(24.dp),
87+ .padding(24.dp)
88+ .verticalScroll(rememberScrollState()),
72 ) {
89 ) {
73 Icon(
90 Icon(
74 imageVector = Icons.Default.Dns,
91 imageVector = Icons.Default.Dns,
@@ -91,7 +108,8 @@ fun ConnectScreen(core: Core) {
91 keyboardType = KeyboardType.Uri,
108 keyboardType = KeyboardType.Uri,
92 imeAction = ImeAction.Go,
109 imeAction = ImeAction.Go,
93 ),
110 ),
111+ keyboardActions = KeyboardActions(onGo = { submit() }),
94− modifier = Modifier.padding(horizontal = 16.dp),
112+ modifier = Modifier.fillMaxWidth(),
95 )
113 )
96
114
97 AnimatedVisibility(visible = view.authRequired) {
115 AnimatedVisibility(visible = view.authRequired) {
@@ -113,7 +131,10 @@ fun ConnectScreen(core: Core) {
113 leadingIcon = {
131 leadingIcon = {
114 Icon(Icons.Default.Lock, contentDescription = null)
132 Icon(Icons.Default.Lock, contentDescription = null)
115 },
133 },
134+ keyboardOptions = KeyboardOptions(
135+ imeAction = ImeAction.Next,
136+ ),
116− modifier = Modifier.padding(horizontal = 16.dp),
137+ modifier = Modifier.fillMaxWidth(),
117 )
138 )
118 Spacer(Modifier.height(8.dp))
139 Spacer(Modifier.height(8.dp))
119 OutlinedTextField(
140 OutlinedTextField(
@@ -121,12 +142,33 @@ fun ConnectScreen(core: Core) {
121 onValueChange = { password = it },
142 onValueChange = { password = it },
122 label = { Text("Password") },
143 label = { Text("Password") },
123 singleLine = true,
144 singleLine = true,
145+ visualTransformation = if (passwordVisible) {
146+ VisualTransformation.None
147+ } else {
124− visualTransformation = PasswordVisualTransformation(),
148+ PasswordVisualTransformation()
149+ },
150+ trailingIcon = {
151+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
152+ Icon(
153+ if (passwordVisible) {
154+ Icons.Default.VisibilityOff
155+ } else {
156+ Icons.Default.Visibility
157+ },
158+ contentDescription = if (passwordVisible) {
159+ "Hide password"
160+ } else {
161+ "Show password"
162+ },
163+ )
164+ }
165+ },
125 keyboardOptions = KeyboardOptions(
166 keyboardOptions = KeyboardOptions(
126 keyboardType = KeyboardType.Password,
167 keyboardType = KeyboardType.Password,
127 imeAction = ImeAction.Go,
168 imeAction = ImeAction.Go,
128 ),
169 ),
170+ keyboardActions = KeyboardActions(onGo = { submit() }),
129− modifier = Modifier.padding(horizontal = 16.dp),
171+ modifier = Modifier.fillMaxWidth(),
130 )
172 )
131 Spacer(Modifier.height(4.dp))
173 Spacer(Modifier.height(4.dp))
132 TextButton(onClick = { core.update(Event.CancelAuth) }) {
174 TextButton(onClick = { core.update(Event.CancelAuth) }) {
@@ -137,15 +179,9 @@ fun ConnectScreen(core: Core) {
137
179
138 Spacer(Modifier.height(16.dp))
180 Spacer(Modifier.height(16.dp))
139 Button(
181 Button(
140− onClick = {
182+ onClick = { submit() },
141− core.update(Event.ServerUrlChanged(url))
142− if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
143− core.update(Event.UsernameChanged(username))
144− core.update(Event.PasswordChanged(password))
145− }
146− core.update(Event.Connect)
147− },
148 enabled = !view.loading,
183 enabled = !view.loading,
184+ modifier = Modifier.fillMaxWidth(),
149 ) {
185 ) {
150 if (view.loading) {
186 if (view.loading) {
151 CircularProgressIndicator(
187 CircularProgressIndicator(
@@ -158,14 +194,78 @@ fun ConnectScreen(core: Core) {
158 }
194 }
159 }
195 }
160
196
161− if (view.crashLogCount > 0u) {
197+ val crashCount = CrashLogger.getReports().size
198+ if (crashCount > 0) {
162 Spacer(Modifier.height(16.dp))
199 Spacer(Modifier.height(16.dp))
200+ Row(verticalAlignment = Alignment.CenterVertically) {
201+ Icon(
202+ Icons.Default.Warning,
203+ contentDescription = null,
204+ tint = MaterialTheme.colorScheme.error,
205+ modifier = Modifier.size(16.dp),
206+ )
207+ Spacer(Modifier.size(6.dp))
208+ TextButton(onClick = { showCrashLogs = true }) {
163− Text(
209+ Text(
164− text = "${view.crashLogCount} crash report(s) recorded",
210+ text = "$crashCount crash report(s) — view",
165− style = MaterialTheme.typography.labelSmall,
211+ style = MaterialTheme.typography.labelSmall,
166− color = MaterialTheme.colorScheme.onSurfaceVariant,
212+ )
167− )
213+ }
214+ }
168 }
215 }
169 }
216 }
170 }
217 }
218+
219+ if (showCrashLogs) {
220+ CrashLogDialog(
221+ reports = CrashLogger.getReports(),
222+ onDismiss = { showCrashLogs = false },
223+ onClear = {
224+ CrashLogger.clearReports()
225+ showCrashLogs = false
226+ },
227+ )
228+ }
229+}
230+
231+@Composable
232+private fun CrashLogDialog(
233+ reports: List<File>,
234+ onDismiss: () -> Unit,
235+ onClear: () -> Unit,
236+) {
237+ AlertDialog(
238+ onDismissRequest = onDismiss,
239+ title = { Text("Crash Reports") },
240+ text = {
241+ Column(
242+ modifier = Modifier
243+ .fillMaxWidth()
244+ .height(360.dp)
245+ .verticalScroll(rememberScrollState()),
246+ ) {
247+ reports.forEachIndexed { index, file ->
248+ Text(
249+ text = file.name,
250+ style = MaterialTheme.typography.labelMedium,
251+ color = MaterialTheme.colorScheme.primary,
252+ )
253+ Spacer(Modifier.height(4.dp))
254+ Text(
255+ text = file.readText(),
256+ style = MaterialTheme.typography.bodySmall,
257+ )
258+ if (index < reports.lastIndex) {
259+ Spacer(Modifier.height(12.dp))
260+ }
261+ }
262+ }
263+ },
264+ confirmButton = {
265+ TextButton(onClick = onClear) { Text("Clear all") }
266+ },
267+ dismissButton = {
268+ TextButton(onClick = onDismiss) { Text("Close") }
269+ },
270+ )
171}
271}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+40−22
@@ -1,6 +1,7 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
2
2
3import androidx.compose.foundation.clickable
3import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.layout.fillMaxSize
@@ -8,18 +9,21 @@ import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
10import androidx.compose.foundation.lazy.items
10import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.Icons
12+import androidx.compose.material.icons.automirrored.filled.ArrowBack
11import androidx.compose.material.icons.filled.Add
13import androidx.compose.material.icons.filled.Add
12import androidx.compose.material.icons.filled.ChatBubble
14import androidx.compose.material.icons.filled.ChatBubble
15+import androidx.compose.material.icons.filled.Settings
13import androidx.compose.material3.CircularProgressIndicator
16import androidx.compose.material3.CircularProgressIndicator
14import androidx.compose.material3.ExperimentalMaterial3Api
17import androidx.compose.material3.ExperimentalMaterial3Api
15−import androidx.compose.material3.FloatingActionButton
16import androidx.compose.material3.Icon
18import androidx.compose.material3.Icon
17import androidx.compose.material3.IconButton
19import androidx.compose.material3.IconButton
18import androidx.compose.material3.ListItem
20import androidx.compose.material3.ListItem
19import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.Scaffold
22import androidx.compose.material3.Scaffold
23+import androidx.compose.material3.SnackbarHost
21import androidx.compose.material3.Text
24import androidx.compose.material3.Text
22import androidx.compose.material3.TopAppBar
25import androidx.compose.material3.TopAppBar
26+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
23import androidx.compose.runtime.Composable
27import androidx.compose.runtime.Composable
24import androidx.compose.runtime.collectAsState
28import androidx.compose.runtime.collectAsState
25import androidx.compose.runtime.getValue
29import androidx.compose.runtime.getValue
@@ -28,28 +32,35 @@ import androidx.compose.ui.Modifier
28import androidx.compose.ui.unit.dp
32import androidx.compose.ui.unit.dp
29import soy.iko.opencode.core.Core
33import soy.iko.opencode.core.Core
30import soy.iko.opencode.core.Event
34import soy.iko.opencode.core.Event
35+import soy.iko.opencode.ui.components.ErrorHost
36+import soy.iko.opencode.ui.components.ErrorSnackbarHost
31
37
32@OptIn(ExperimentalMaterial3Api::class)
38@OptIn(ExperimentalMaterial3Api::class)
33@Composable
39@Composable
34fun SessionsScreen(core: Core) {
40fun SessionsScreen(core: Core) {
35 val view by core.view.collectAsState()
41 val view by core.view.collectAsState()
42+ val snackbarHostState = ErrorHost(core)
36
43
37 Scaffold(
44 Scaffold(
38 topBar = {
45 topBar = {
39 TopAppBar(
46 TopAppBar(
40 title = { Text("Sessions") },
47 title = { Text("Sessions") },
48+ navigationIcon = {
49+ IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
50+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Change server")
51+ }
52+ },
41 actions = {
53 actions = {
42 IconButton(onClick = { core.update(Event.CreateSession) }) {
54 IconButton(onClick = { core.update(Event.CreateSession) }) {
43 Icon(Icons.Default.Add, contentDescription = "New session")
55 Icon(Icons.Default.Add, contentDescription = "New session")
44 }
56 }
57+ IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
58+ Icon(Icons.Default.Settings, contentDescription = "Settings")
59+ }
45 },
60 },
46 )
61 )
47 },
62 },
48− floatingActionButton = {
63+ snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
49− FloatingActionButton(onClick = { core.update(Event.CreateSession) }) {
50− Icon(Icons.Default.Add, contentDescription = "New session")
51− }
52− },
53 ) { padding ->
64 ) { padding ->
54 Box(
65 Box(
55 modifier = Modifier
66 modifier = Modifier
@@ -63,6 +74,7 @@ fun SessionsScreen(core: Core) {
63 } else if (view.sessions.isEmpty()) {
74 } else if (view.sessions.isEmpty()) {
64 Column(
75 Column(
65 horizontalAlignment = Alignment.CenterHorizontally,
76 horizontalAlignment = Alignment.CenterHorizontally,
77+ verticalArrangement = Arrangement.Center,
66 modifier = Modifier
78 modifier = Modifier
67 .fillMaxSize()
79 .fillMaxSize()
68 .padding(24.dp),
80 .padding(24.dp),
@@ -74,24 +86,30 @@ fun SessionsScreen(core: Core) {
74 )
86 )
75 }
87 }
76 } else {
88 } else {
89+ PullToRefreshBox(
90+ isRefreshing = view.loading,
91+ onRefresh = { core.update(Event.LoadSessions) },
92+ modifier = Modifier.fillMaxSize(),
93+ ) {
77− LazyColumn {
94+ LazyColumn {
78− items(view.sessions, key = { it.id }) { session ->
95+ items(view.sessions, key = { it.id }) { session ->
79− ListItem(
96+ ListItem(
97+ headlineContent = {
80− headlineContent = { Text(session.title.ifEmpty { "Untitled" }) },
98+ Text(session.title.ifEmpty { "Untitled" })
99+ },
81− supportingContent = { Text(session.id.take(8)) },
100+ supportingContent = { Text(session.id.take(8)) },
82− leadingContent = {
101+ leadingContent = {
83− Icon(
102+ Icon(
84− Icons.Default.ChatBubble,
103+ Icons.Default.ChatBubble,
85− contentDescription = null,
104+ contentDescription = null,
86− tint = MaterialTheme.colorScheme.primary,
105+ tint = MaterialTheme.colorScheme.primary,
87− )
106+ )
88− },
107+ },
89− modifier = Modifier
108+ modifier = Modifier.clickable {
90− .padding(horizontal = 0.dp)
91− .clickable {
92 core.update(Event.SelectSession(session.id))
109 core.update(Event.SelectSession(session.id))
93 },
110 },
94− )
111+ )
112+ }
95 }
113 }
96 }
114 }
97 }
115 }
generated/soy/iko/opencode/core/Core.kt+23−3
@@ -363,13 +363,25 @@ sealed interface Event {
363 }
363 }
364 }
364 }
365
365
366− data object DismissError: Event {
366+ data object NavigateToConnect: Event {
367 override fun serialize(serializer: Serializer) {
367 override fun serialize(serializer: Serializer) {
368 serializer.increase_container_depth()
368 serializer.increase_container_depth()
369 serializer.serialize_variant_index(14)
369 serializer.serialize_variant_index(14)
370 serializer.decrease_container_depth()
370 serializer.decrease_container_depth()
371 }
371 }
372
372
373+ fun deserialize(deserializer: Deserializer): NavigateToConnect {
374+ return NavigateToConnect
375+ }
376+ }
377+
378+ data object DismissError: Event {
379+ override fun serialize(serializer: Serializer) {
380+ serializer.increase_container_depth()
381+ serializer.serialize_variant_index(15)
382+ serializer.decrease_container_depth()
383+ }
384+
373 fun deserialize(deserializer: Deserializer): DismissError {
385 fun deserialize(deserializer: Deserializer): DismissError {
374 return DismissError
386 return DismissError
375 }
387 }
@@ -394,7 +406,8 @@ sealed interface Event {
394 11 -> EventReceived.deserialize(deserializer)
406 11 -> EventReceived.deserialize(deserializer)
395 12 -> NavigateToChat.deserialize(deserializer)
407 12 -> NavigateToChat.deserialize(deserializer)
396 13 -> NavigateToSessions.deserialize(deserializer)
408 13 -> NavigateToSessions.deserialize(deserializer)
409+ 14 -> NavigateToConnect.deserialize(deserializer)
397− 14 -> DismissError.deserialize(deserializer)
410+ 15 -> DismissError.deserialize(deserializer)
398 else -> throw DeserializationError("Unknown variant index for Event: $index")
411 else -> throw DeserializationError("Unknown variant index for Event: $index")
399 }
412 }
400 }
413 }
@@ -1054,8 +1067,11 @@ data class ViewModel(
1054 val error: String? = null,
1067 val error: String? = null,
1055 val sessions: List<soy.iko.opencode.core.SessionView>,
1068 val sessions: List<soy.iko.opencode.core.SessionView>,
1056 val currentSessionId: String? = null,
1069 val currentSessionId: String? = null,
1070+ val currentSessionTitle: String,
1057 val messages: List<soy.iko.opencode.core.MessageView>,
1071 val messages: List<soy.iko.opencode.core.MessageView>,
1058 val draftMessage: String,
1072 val draftMessage: String,
1073+ /// True while waiting for the assistant to reply after the user sends.
1074+ val generating: Boolean,
1059 val crashLogCount: UInt,
1075 val crashLogCount: UInt,
1060 val latestCrashLog: String? = null,
1076 val latestCrashLog: String? = null,
1061) {
1077) {
@@ -1077,10 +1093,12 @@ data class ViewModel(
1077 currentSessionId.serializeOptionOf(serializer) {
1093 currentSessionId.serializeOptionOf(serializer) {
1078 serializer.serialize_str(it)
1094 serializer.serialize_str(it)
1079 }
1095 }
1096+ serializer.serialize_str(currentSessionTitle)
1080 messages.serialize(serializer) {
1097 messages.serialize(serializer) {
1081 it.serialize(serializer)
1098 it.serialize(serializer)
1082 }
1099 }
1083 serializer.serialize_str(draftMessage)
1100 serializer.serialize_str(draftMessage)
1101+ serializer.serialize_bool(generating)
1084 serializer.serialize_u32(crashLogCount)
1102 serializer.serialize_u32(crashLogCount)
1085 latestCrashLog.serializeOptionOf(serializer) {
1103 latestCrashLog.serializeOptionOf(serializer) {
1086 serializer.serialize_str(it)
1104 serializer.serialize_str(it)
@@ -1116,18 +1134,20 @@ data class ViewModel(
1116 deserializer.deserializeOptionOf {
1134 deserializer.deserializeOptionOf {
1117 deserializer.deserialize_str()
1135 deserializer.deserialize_str()
1118 }
1136 }
1137+ val currentSessionTitle = deserializer.deserialize_str()
1119 val messages =
1138 val messages =
1120 deserializer.deserializeListOf {
1139 deserializer.deserializeListOf {
1121 soy.iko.opencode.core.MessageView.deserialize(deserializer)
1140 soy.iko.opencode.core.MessageView.deserialize(deserializer)
1122 }
1141 }
1123 val draftMessage = deserializer.deserialize_str()
1142 val draftMessage = deserializer.deserialize_str()
1143+ val generating = deserializer.deserialize_bool()
1124 val crashLogCount = deserializer.deserialize_u32()
1144 val crashLogCount = deserializer.deserialize_u32()
1125 val latestCrashLog =
1145 val latestCrashLog =
1126 deserializer.deserializeOptionOf {
1146 deserializer.deserializeOptionOf {
1127 deserializer.deserialize_str()
1147 deserializer.deserialize_str()
1128 }
1148 }
1129 deserializer.decrease_container_depth()
1149 deserializer.decrease_container_depth()
1130− return ViewModel(screen, serverUrl, username, password, authRequired, connected, loading, error, sessions, currentSessionId, messages, draftMessage, crashLogCount, latestCrashLog)
1150+ return ViewModel(screen, serverUrl, username, password, authRequired, connected, loading, error, sessions, currentSessionId, currentSessionTitle, messages, draftMessage, generating, crashLogCount, latestCrashLog)
1131 }
1151 }
1132
1152
1133 @Throws(DeserializationError::class)
1153 @Throws(DeserializationError::class)
gradle.properties+1
@@ -2,3 +2,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2android.useAndroidX=true
2android.useAndroidX=true
3kotlin.code.style=official
3kotlin.code.style=official
4android.nonTransitiveRClass=true
4android.nonTransitiveRClass=true
5+android.suppressUnsupportedCompileSdk=37.0
gradle/libs.versions.toml+4
@@ -12,6 +12,7 @@ lifecycle = "2.10.0"
12ktor = "3.1.3"
12ktor = "3.1.3"
13kotlinxCoroutines = "1.10.2"
13kotlinxCoroutines = "1.10.2"
14navigationCompose = "2.9.8"
14navigationCompose = "2.9.8"
15+markdownRenderer = "0.43.0"
15[libraries]
16[libraries]
16androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
17androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
17junit = { group = "junit", name = "junit", version.ref = "junit" }
18junit = { group = "junit", name = "junit", version.ref = "junit" }
@@ -31,6 +32,9 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material"
31lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
32lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
32androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
33androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
33
34
35+markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3-android", version.ref = "markdownRenderer" }
36+markdown-renderer-core = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }
37+
34ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
38ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
35ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
39ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
36ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
40ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
shared/build.gradle.kts+2−2
@@ -8,8 +8,8 @@ plugins {
8android {
8android {
9 namespace = "soy.iko.opencode.core"
9 namespace = "soy.iko.opencode.core"
10
10
11− compileSdk = 36
11+ compileSdk = 37
12− buildToolsVersion = "36.0.0"
12+ buildToolsVersion = "37.0.0"
13
13
14 defaultConfig {
14 defaultConfig {
15 minSdk = 26
15 minSdk = 26
shared/src/app.rs+118−5
@@ -42,6 +42,7 @@ pub enum Event {
42 // Navigation
42 // Navigation
43 NavigateToChat(String),
43 NavigateToChat(String),
44 NavigateToSessions,
44 NavigateToSessions,
45+ NavigateToConnect,
45
46
46 // Errors
47 // Errors
47 DismissError,
48 DismissError,
@@ -101,8 +102,11 @@ pub struct Model {
101 error: Option<String>,
102 error: Option<String>,
102 sessions: Vec<SessionSummary>,
103 sessions: Vec<SessionSummary>,
103 current_session_id: Option<String>,
104 current_session_id: Option<String>,
105+ current_session_title: String,
104 messages: Vec<MessageView>,
106 messages: Vec<MessageView>,
105 draft_message: String,
107 draft_message: String,
108+ /// True between sending a message and the assistant reply arriving.
109+ generating: bool,
106 /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
110 /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
107 crash_logs: Vec<String>,
111 crash_logs: Vec<String>,
108}
112}
@@ -135,8 +139,11 @@ pub struct ViewModel {
135 pub error: Option<String>,
139 pub error: Option<String>,
136 pub sessions: Vec<SessionView>,
140 pub sessions: Vec<SessionView>,
137 pub current_session_id: Option<String>,
141 pub current_session_id: Option<String>,
142+ pub current_session_title: String,
138 pub messages: Vec<MessageView>,
143 pub messages: Vec<MessageView>,
139 pub draft_message: String,
144 pub draft_message: String,
145+ /// True while waiting for the assistant to reply after the user sends.
146+ pub generating: bool,
140 pub crash_log_count: u32,
147 pub crash_log_count: u32,
141 pub latest_crash_log: Option<String>,
148 pub latest_crash_log: Option<String>,
142}
149}
@@ -216,7 +223,8 @@ impl App for OpencodeApp {
216 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
223 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
217 match event {
224 match event {
218 Event::Start => {
225 Event::Start => {
219− model.server_url = "http://localhost:4096".to_string();
226+ // Don't reset server_url here — the shell restores it from
227+ // persisted storage via ServerUrlChanged before any connect.
220 render()
228 render()
221 }
229 }
222
230
@@ -264,6 +272,13 @@ impl App for OpencodeApp {
264 let body = response.take_body().unwrap_or_default();
272 let body = response.take_body().unwrap_or_default();
265 model.sessions = parse_sessions(&body);
273 model.sessions = parse_sessions(&body);
266 model.connected = true;
274 model.connected = true;
275+ // Refresh the current session's title in case it was
276+ // populated after creation.
277+ if let Some(id) = model.current_session_id.clone() {
278+ if let Some(s) = model.sessions.iter().find(|s| s.id == id) {
279+ model.current_session_title = s.title.clone();
280+ }
281+ }
267 render()
282 render()
268 }
283 }
269 Err(e) => {
284 Err(e) => {
@@ -275,18 +290,45 @@ impl App for OpencodeApp {
275
290
276 Event::SelectSession(id) => {
291 Event::SelectSession(id) => {
277 model.current_session_id = Some(id.clone());
292 model.current_session_id = Some(id.clone());
293+ model.current_session_title = model
294+ .sessions
295+ .iter()
296+ .find(|s| s.id == id)
297+ .map(|s| s.title.clone())
298+ .unwrap_or_default();
278 Command::event(Event::NavigateToChat(id))
299 Command::event(Event::NavigateToChat(id))
279 }
300 }
280
301
281 Event::NavigateToChat(id) => {
302 Event::NavigateToChat(id) => {
282 model.current_session_id = Some(id.clone());
303 model.current_session_id = Some(id.clone());
304+ model.current_session_title = model
305+ .sessions
306+ .iter()
307+ .find(|s| s.id == id)
308+ .map(|s| s.title.clone())
309+ .unwrap_or_default();
283 model.messages.clear();
310 model.messages.clear();
284 render().and(Command::event(Event::LoadMessages(id)))
311 render().and(Command::event(Event::LoadMessages(id)))
285 }
312 }
286
313
287 Event::NavigateToSessions => {
314 Event::NavigateToSessions => {
288 model.current_session_id = None;
315 model.current_session_id = None;
316+ model.current_session_title.clear();
317+ model.messages.clear();
318+ model.generating = false;
319+ render()
320+ }
321+
322+ Event::NavigateToConnect => {
323+ // Return to the connect screen to switch servers / credentials.
324+ // Keep the sessions list so a re-connect can refresh in place.
325+ model.current_session_id = None;
326+ model.current_session_title.clear();
289 model.messages.clear();
327 model.messages.clear();
328+ model.generating = false;
329+ model.connected = false;
330+ model.auth_required = false;
331+ model.loading = false;
290 render()
332 render()
291 }
333 }
292
334
@@ -303,6 +345,16 @@ impl App for OpencodeApp {
303 Ok(mut response) => {
345 Ok(mut response) => {
304 let body = response.take_body().unwrap_or_default();
346 let body = response.take_body().unwrap_or_default();
305 let id = parse_session_id(&body);
347 let id = parse_session_id(&body);
348+ // Some servers echo the title in the created payload;
349+ // fall back to "New session" if absent.
350+ let title = extract_string(&body, "title")
351+ .filter(|t| !t.is_empty())
352+ .unwrap_or_else(|| "New session".to_string());
353+ model.current_session_title = title.clone();
354+ model.sessions.insert(
355+ 0,
356+ SessionSummary { id: id.clone(), title },
357+ );
306 Command::event(Event::NavigateToChat(id))
358 Command::event(Event::NavigateToChat(id))
307 }
359 }
308 Err(e) => {
360 Err(e) => {
@@ -320,6 +372,7 @@ impl App for OpencodeApp {
320
372
321 Event::MessagesLoaded(result) => {
373 Event::MessagesLoaded(result) => {
322 model.loading = false;
374 model.loading = false;
375+ model.generating = false;
323 match result {
376 match result {
324 Ok(mut response) => {
377 Ok(mut response) => {
325 let body = response.take_body().unwrap_or_default();
378 let body = response.take_body().unwrap_or_default();
@@ -338,6 +391,7 @@ impl App for OpencodeApp {
338 return render();
391 return render();
339 };
392 };
340 model.draft_message.clear();
393 model.draft_message.clear();
394+ model.generating = true;
341 let body = serde_json::json!({
395 let body = serde_json::json!({
342 "sessionID": session_id,
396 "sessionID": session_id,
343 "parts": [{ "type": "text", "text": text }]
397 "parts": [{ "type": "text", "text": text }]
@@ -357,10 +411,12 @@ impl App for OpencodeApp {
357 if let Some(session_id) = model.current_session_id.clone() {
411 if let Some(session_id) = model.current_session_id.clone() {
358 Command::event(Event::LoadMessages(session_id))
412 Command::event(Event::LoadMessages(session_id))
359 } else {
413 } else {
414+ model.generating = false;
360 render()
415 render()
361 }
416 }
362 }
417 }
363 Err(e) => {
418 Err(e) => {
419+ model.generating = false;
364 model.error = Some(format!("Failed to send message: {e}"));
420 model.error = Some(format!("Failed to send message: {e}"));
365 render()
421 render()
366 }
422 }
@@ -418,8 +474,10 @@ impl App for OpencodeApp {
418 })
474 })
419 .collect(),
475 .collect(),
420 current_session_id: model.current_session_id.clone(),
476 current_session_id: model.current_session_id.clone(),
477+ current_session_title: model.current_session_title.clone(),
421 messages: model.messages.clone(),
478 messages: model.messages.clone(),
422 draft_message: model.draft_message.clone(),
479 draft_message: model.draft_message.clone(),
480+ generating: model.generating,
423 crash_log_count: model.crash_logs.len() as u32,
481 crash_log_count: model.crash_logs.len() as u32,
424 latest_crash_log: model.crash_logs.last().cloned(),
482 latest_crash_log: model.crash_logs.last().cloned(),
425 }
483 }
@@ -604,11 +662,15 @@ mod test {
604 use super::*;
662 use super::*;
605
663
606 #[test]
664 #[test]
607− fn start_sets_default_server_url() {
665+ fn start_does_not_reset_server_url() {
666+ // Start no longer overwrites server_url — the shell restores the
667+ // persisted value via ServerUrlChanged so the user's last server
668+ // survives a relaunch.
608 let app = OpencodeApp;
669 let app = OpencodeApp;
609 let mut model = Model::default();
670 let mut model = Model::default();
671+ model.server_url = "http://10.0.0.1:4096".to_string();
610 app.update(Event::Start, &mut model).expect_only_render();
672 app.update(Event::Start, &mut model).expect_only_render();
611− assert_eq!(model.server_url, "http://localhost:4096");
673+ assert_eq!(model.server_url, "http://10.0.0.1:4096");
612 }
674 }
613
675
614 #[test]
676 #[test]
@@ -628,6 +690,7 @@ mod test {
628 fn connect_emits_http_effect() {
690 fn connect_emits_http_effect() {
629 let app = OpencodeApp;
691 let app = OpencodeApp;
630 let mut model = Model::default();
692 let mut model = Model::default();
693+ model.server_url = "http://localhost:4096".to_string();
631 app.update(Event::Start, &mut model);
694 app.update(Event::Start, &mut model);
632 let mut cmd = app.update(Event::Connect, &mut model);
695 let mut cmd = app.update(Event::Connect, &mut model);
633 assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
696 assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
@@ -669,6 +732,8 @@ mod test {
669 let app = OpencodeApp;
732 let app = OpencodeApp;
670 let mut model = Model::default();
733 let mut model = Model::default();
671 model.current_session_id = Some("test".to_string());
734 model.current_session_id = Some("test".to_string());
735+ model.current_session_title = "Test".to_string();
736+ model.generating = true;
672 model.messages.push(MessageView {
737 model.messages.push(MessageView {
673 id: "m1".to_string(),
738 id: "m1".to_string(),
674 role: "user".to_string(),
739 role: "user".to_string(),
@@ -677,7 +742,53 @@ mod test {
677 });
742 });
678 app.update(Event::NavigateToSessions, &mut model);
743 app.update(Event::NavigateToSessions, &mut model);
679 assert!(model.current_session_id.is_none());
744 assert!(model.current_session_id.is_none());
745+ assert!(model.current_session_title.is_empty());
680 assert!(model.messages.is_empty());
746 assert!(model.messages.is_empty());
747+ assert!(!model.generating);
748+ }
749+
750+ #[test]
751+ fn select_session_records_title_from_sessions_list() {
752+ let app = OpencodeApp;
753+ let mut model = Model::default();
754+ model.sessions = vec![
755+ SessionSummary { id: "s1".to_string(), title: "First".to_string() },
756+ SessionSummary { id: "s2".to_string(), title: "Second".to_string() },
757+ ];
758+ app.update(Event::SelectSession("s2".to_string()), &mut model);
759+ assert_eq!(model.current_session_id.as_deref(), Some("s2"));
760+ assert_eq!(model.current_session_title, "Second");
761+ let view = app.view(&model);
762+ assert_eq!(view.current_session_title, "Second");
763+ }
764+
765+ #[test]
766+ fn send_message_sets_generating_until_messages_loaded() {
767+ let app = OpencodeApp;
768+ let mut model = Model::default();
769+ model.server_url = "http://localhost:4096".to_string();
770+ model.current_session_id = Some("s1".to_string());
771+ app.update(Event::SendMessage("hello".to_string()), &mut model);
772+ assert!(model.generating);
773+ // Simulate the MessagesLoaded internal event clearing it.
774+ let ok_resp = crux_http::testing::ResponseBuilder::ok().body("[]".to_string()).build();
775+ let result: HttpResult<String> = Ok(ok_resp);
776+ app.update(Event::MessagesLoaded(result), &mut model);
777+ assert!(!model.generating);
778+ }
779+
780+ #[test]
781+ fn failed_send_clears_generating() {
782+ let app = OpencodeApp;
783+ let mut model = Model::default();
784+ model.server_url = "http://localhost:4096".to_string();
785+ model.current_session_id = Some("s1".to_string());
786+ app.update(Event::SendMessage("hello".to_string()), &mut model);
787+ assert!(model.generating);
788+ let err = crux_http::HttpError::Io("boom".to_string());
789+ app.update(Event::MessageSent(Err(err)), &mut model);
790+ assert!(!model.generating);
791+ assert!(model.error.is_some());
681 }
792 }
682
793
683 #[test]
794 #[test]
@@ -717,7 +828,8 @@ mod test {
717 // 0=Start, 1=ServerUrlChanged, 2=UsernameChanged, 3=PasswordChanged,
828 // 0=Start, 1=ServerUrlChanged, 2=UsernameChanged, 3=PasswordChanged,
718 // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
829 // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
719 // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
830 // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
720− // 12=NavigateToChat, 13=NavigateToSessions, 14=DismissError
831+ // 12=NavigateToChat, 13=NavigateToSessions, 14=NavigateToConnect,
832+ // 15=DismissError
721 //
833 //
722 // Serialize each Rust variant and check the leading u32 index.
834 // Serialize each Rust variant and check the leading u32 index.
723 let cases: &[(Event, u32)] = &[
835 let cases: &[(Event, u32)] = &[
@@ -735,7 +847,8 @@ mod test {
735 (Event::EventReceived(String::new()), 11),
847 (Event::EventReceived(String::new()), 11),
736 (Event::NavigateToChat(String::new()), 12),
848 (Event::NavigateToChat(String::new()), 12),
737 (Event::NavigateToSessions, 13),
849 (Event::NavigateToSessions, 13),
738− (Event::DismissError, 14),
850+ (Event::NavigateToConnect, 14),
851+ (Event::DismissError, 15),
739 ];
852 ];
740 for (event, expected) in cases {
853 for (event, expected) in cases {
741 let bytes = bincode::serialize(event).unwrap();
854 let bytes = bincode::serialize(event).unwrap();