git.iko.soy/opencode-android
on master help sign in
Improve UI/UX with design tokens, string resources, reusable components, IME-aware chat input, SSE disconnection banner, success/retry feedback, accessibility semantics, directional screen transitions, and Compose previews
4c143bbiko
app/build.gradle.kts−1
@@ -74,7 +74,6 @@ dependencies {
74 implementation(libs.androidx.compose.material3)
74 implementation(libs.androidx.compose.material3)
75 implementation(libs.androidx.compose.material.icons.extended)
75 implementation(libs.androidx.compose.material.icons.extended)
76 implementation(libs.lifecycle.viewmodel.compose)
76 implementation(libs.lifecycle.viewmodel.compose)
77− implementation(libs.androidx.navigation.compose)
78
77
79 implementation(libs.kotlinx.coroutines.android)
78 implementation(libs.kotlinx.coroutines.android)
80 implementation(libs.markdown.renderer.core)
79 implementation(libs.markdown.renderer.core)
app/src/main/java/soy/iko/opencode/MainActivity.kt+18−2
@@ -9,6 +9,8 @@ import androidx.compose.animation.AnimatedContent
9import androidx.compose.animation.togetherWith
9import androidx.compose.animation.togetherWith
10import androidx.compose.animation.fadeIn
10import androidx.compose.animation.fadeIn
11import androidx.compose.animation.fadeOut
11import androidx.compose.animation.fadeOut
12+import androidx.compose.animation.slideInHorizontally
13+import androidx.compose.animation.slideOutHorizontally
12import androidx.compose.runtime.Composable
14import androidx.compose.runtime.Composable
13import androidx.compose.runtime.collectAsState
15import androidx.compose.runtime.collectAsState
14import androidx.compose.runtime.getValue
16import androidx.compose.runtime.getValue
@@ -47,10 +49,24 @@ fun App(core: Core = viewModel()) {
47 }
49 }
48 }
50 }
49
51
50− // Crossfade between screens for a smoother navigation feel.
52+ // Directional slide between screens for a smoother navigation feel:
53+ // forward navigation slides the new screen in from the end side, back
54+ // navigation slides it in from the start side. A short fade is layered on
55+ // so the transition doesn't feel harsh. The TopAppBar still fades with
56+ // each screen, but the directional motion reads as natural page changes
57+ // rather than a flat crossfade.
51 AnimatedContent(
58 AnimatedContent(
52 targetState = view.screen,
59 targetState = view.screen,
53− transitionSpec = { fadeIn() togetherWith fadeOut() },
60+ transitionSpec = {
61+ val forward = targetState.ordinal > initialState.ordinal
62+ if (forward) {
63+ slideInHorizontally(initialOffsetX = { it / 3 }) + fadeIn() togetherWith
64+ slideOutHorizontally(targetOffsetX = { -it / 6 }) + fadeOut()
65+ } else {
66+ slideInHorizontally(initialOffsetX = { -it / 6 }) + fadeIn() togetherWith
67+ slideOutHorizontally(targetOffsetX = { it / 3 }) + fadeOut()
68+ }
69+ },
54 label = "screen-transition",
70 label = "screen-transition",
55 ) { screen ->
71 ) { screen ->
56 when (screen) {
72 when (screen) {
app/src/main/java/soy/iko/opencode/core/Core.kt+73
@@ -3,8 +3,11 @@ package soy.iko.opencode.core
3import android.app.Application
3import android.app.Application
4import androidx.lifecycle.AndroidViewModel
4import androidx.lifecycle.AndroidViewModel
5import androidx.lifecycle.viewModelScope
5import androidx.lifecycle.viewModelScope
6+import kotlinx.coroutines.flow.MutableSharedFlow
6import kotlinx.coroutines.flow.MutableStateFlow
7import kotlinx.coroutines.flow.MutableStateFlow
8+import kotlinx.coroutines.flow.SharedFlow
7import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.StateFlow
10+import kotlinx.coroutines.flow.asSharedFlow
8import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.asStateFlow
9import kotlinx.coroutines.launch
12import kotlinx.coroutines.launch
10import soy.iko.opencode.CrashLogger
13import soy.iko.opencode.CrashLogger
@@ -35,6 +38,21 @@ class Core(application: Application) : AndroidViewModel(application) {
35 private val _view = MutableStateFlow(getInitialView())
38 private val _view = MutableStateFlow(getInitialView())
36 val view: StateFlow<ViewModel> = _view.asStateFlow()
39 val view: StateFlow<ViewModel> = _view.asStateFlow()
37
40
41+ /** Live status of the SSE event stream, for the "disconnected" banner. */
42+ val sseState: StateFlow<SseState> = sseClient.state
43+
44+ /**
45+ * Transient, user-facing success/info notifications (e.g. "Session
46+ * created", "Connected"). Consumed by the UI as a snackbar. This is a
47+ * SharedFlow so a configuration change doesn't re-fire an old message.
48+ */
49+ private val _info = MutableSharedFlow<String>(extraBufferCapacity = 4)
50+ val info: SharedFlow<String> = _info.asSharedFlow()
51+
52+ /** The last event the user triggered, so an error snackbar can retry it. */
53+ private var lastUserEvent: Event? = null
54+ val retryableEvent: Event? get() = lastUserEvent
55+
38 init {
56 init {
39 // Restore the persisted server URL before the first connect so the
57 // Restore the persisted server URL before the first connect so the
40 // user doesn't have to re-enter it on every launch.
58 // user doesn't have to re-enter it on every launch.
@@ -54,6 +72,10 @@ class Core(application: Application) : AndroidViewModel(application) {
54 }
72 }
55
73
56 fun update(event: Event) {
74 fun update(event: Event) {
75+ // Track user-initiated events so an error snackbar can offer Retry.
76+ if (event.isRetryable()) {
77+ lastUserEvent = event
78+ }
57 val effectsBytes = ffi.update(event.bincodeSerialize())
79 val effectsBytes = ffi.update(event.bincodeSerialize())
58 val requests = Requests.bincodeDeserialize(effectsBytes).value
80 val requests = Requests.bincodeDeserialize(effectsBytes).value
59 for (request in requests) {
81 for (request in requests) {
@@ -61,10 +83,25 @@ class Core(application: Application) : AndroidViewModel(application) {
61 }
83 }
62 }
84 }
63
85
86+ /**
87+ * Re-send the last user-initiated event, if any. Wired to the snackbar's
88+ * "Retry" action.
89+ */
90+ fun retry() {
91+ val last = lastUserEvent ?: return
92+ update(last)
93+ }
94+
95+ /** Re-establish the SSE stream after a drop. Wired to the banner button. */
96+ fun reconnectSse() {
97+ sseClient.reconnect()
98+ }
99+
64 private fun processEffect(request: Request) {
100 private fun processEffect(request: Request) {
65 when (val effect = request.effect) {
101 when (val effect = request.effect) {
66 is Effect.Render -> {
102 is Effect.Render -> {
67 refreshView()
103 refreshView()
104+ emitSuccessInfo()
68 }
105 }
69 is Effect.Http -> {
106 is Effect.Http -> {
70 handleHttpEffect(effect.value, request.id)
107 handleHttpEffect(effect.value, request.id)
@@ -81,6 +118,7 @@ class Core(application: Application) : AndroidViewModel(application) {
81 processEffect(req)
118 processEffect(req)
82 }
119 }
83 refreshView()
120 refreshView()
121+ emitSuccessInfo()
84
122
85 val currentView = _view.value
123 val currentView = _view.value
86 if (currentView.connected && currentView.currentSessionId != null) {
124 if (currentView.connected && currentView.currentSessionId != null) {
@@ -89,6 +127,29 @@ class Core(application: Application) : AndroidViewModel(application) {
89 }
127 }
90 }
128 }
91
129
130+ /**
131+ * Derive a brief success/info notification from the latest view state —
132+ * e.g. when the user just connected or created a session. The UI surfaces
133+ * these as a snackbar so the user gets positive feedback, not just screen
134+ * changes.
135+ */
136+ private fun emitSuccessInfo() {
137+ val v = _view.value
138+ val prev = previousScreen
139+ when {
140+ v.connected && prev == Screen.CONNECT -> {
141+ viewModelScope.launch { _info.emit(SUCCESS_CONNECTED) }
142+ }
143+ v.currentSessionId != null && prev == Screen.SESSIONS &&
144+ lastUserEvent is Event.CreateSession -> {
145+ viewModelScope.launch { _info.emit(SUCCESS_SESSION_CREATED) }
146+ }
147+ }
148+ previousScreen = v.screen
149+ }
150+
151+ private var previousScreen: Screen = _view.value.screen
152+
92 private fun refreshView() {
153 private fun refreshView() {
93 val newView = ViewModel.bincodeDeserialize(ffi.view())
154 val newView = ViewModel.bincodeDeserialize(ffi.view())
94 _view.value = newView
155 _view.value = newView
@@ -142,8 +203,20 @@ class Core(application: Application) : AndroidViewModel(application) {
142 sseClient.disconnect()
203 sseClient.disconnect()
143 }
204 }
144
205
206+ private fun Event.isRetryable(): Boolean = when (this) {
207+ is Event.Connect,
208+ is Event.LoadSessions,
209+ is Event.CreateSession,
210+ is Event.LoadMessages,
211+ is Event.SendMessage -> true
212+ else -> false
213+ }
214+
145 companion object {
215 companion object {
146 private const val PREFS_NAME = "opencode_prefs"
216 private const val PREFS_NAME = "opencode_prefs"
147 private const val KEY_SERVER_URL = "server_url"
217 private const val KEY_SERVER_URL = "server_url"
218+
219+ const val SUCCESS_CONNECTED = "__success_connected__"
220+ const val SUCCESS_SESSION_CREATED = "__success_session_created__"
148 }
221 }
149}
222}
app/src/main/java/soy/iko/opencode/core/SseClient.kt+40−2
@@ -11,8 +11,11 @@ import kotlinx.coroutines.Dispatchers
11import kotlinx.coroutines.Job
11import kotlinx.coroutines.Job
12import kotlinx.coroutines.SupervisorJob
12import kotlinx.coroutines.SupervisorJob
13import kotlinx.coroutines.flow.MutableSharedFlow
13import kotlinx.coroutines.flow.MutableSharedFlow
14+import kotlinx.coroutines.flow.MutableStateFlow
14import kotlinx.coroutines.flow.SharedFlow
15import kotlinx.coroutines.flow.SharedFlow
16+import kotlinx.coroutines.flow.StateFlow
15import kotlinx.coroutines.flow.asSharedFlow
17import kotlinx.coroutines.flow.asSharedFlow
18+import kotlinx.coroutines.flow.asStateFlow
16import kotlinx.coroutines.launch
19import kotlinx.coroutines.launch
17import soy.iko.opencode.CrashLogger
20import soy.iko.opencode.CrashLogger
18
21
@@ -22,8 +25,10 @@ import soy.iko.opencode.CrashLogger
22 * Events are emitted as raw strings on [events]. The Core converts them into
25 * Events are emitted as raw strings on [events]. The Core converts them into
23 * [Event.EventReceived] which triggers a message reload.
26 * [Event.EventReceived] which triggers a message reload.
24 *
27 *
28+ * The live connection status is exposed via [state] so the UI can surface a
29+ * "live stream disconnected" banner to the user instead of silently dropping
25− * Connection errors are reported to [CrashLogger] so they appear in the crash
30+ * updates. Transport errors are still reported to [CrashLogger] for
26− * log alongside any uncaught exceptions.
31+ * diagnostics, but they are no longer hidden from the user.
27 */
32 */
28class SseClient {
33class SseClient {
29 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
34 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -38,13 +43,20 @@ class SseClient {
38 private val _events = MutableSharedFlow<String>(extraBufferCapacity = 64)
43 private val _events = MutableSharedFlow<String>(extraBufferCapacity = 64)
39 val events: SharedFlow<String> = _events.asSharedFlow()
44 val events: SharedFlow<String> = _events.asSharedFlow()
40
45
46+ private val _state = MutableStateFlow<SseState>(SseState.Disconnected)
47+ val state: StateFlow<SseState> = _state.asStateFlow()
48+
41 private var currentJob: Job? = null
49 private var currentJob: Job? = null
50+ @Volatile private var currentUrl: String? = null
42
51
43 fun connect(url: String) {
52 fun connect(url: String) {
53+ currentUrl = url
44 currentJob?.cancel()
54 currentJob?.cancel()
55+ _state.value = SseState.Connecting
45 currentJob = scope.launch {
56 currentJob = scope.launch {
46 try {
57 try {
47 httpClient.prepareGet(url).execute { response ->
58 httpClient.prepareGet(url).execute { response ->
59+ _state.value = SseState.Connected
48 val channel = response.bodyAsChannel()
60 val channel = response.bodyAsChannel()
49 val dataBuilder = StringBuilder()
61 val dataBuilder = StringBuilder()
50 while (!channel.isClosedForRead) {
62 while (!channel.isClosedForRead) {
@@ -56,15 +68,41 @@ class SseClient {
56 dataBuilder.clear()
68 dataBuilder.clear()
57 }
69 }
58 }
70 }
71+ // Stream ended normally — flag as disconnected so the UI
72+ // can offer a reconnect.
73+ _state.value = SseState.Disconnected
59 }
74 }
75+ } catch (e: kotlinx.coroutines.CancellationException) {
76+ _state.value = SseState.Disconnected
77+ throw e
60 } catch (e: Throwable) {
78 } catch (e: Throwable) {
61 CrashLogger.report(e, "SseClient")
79 CrashLogger.report(e, "SseClient")
80+ _state.value = SseState.Error(e.message ?: "Connection lost")
62 }
81 }
63 }
82 }
64 }
83 }
65
84
85+ /** Re-establish the previous connection, if any. */
86+ fun reconnect() {
87+ currentUrl?.let { connect(it) }
88+ }
89+
66 fun disconnect() {
90 fun disconnect() {
67 currentJob?.cancel()
91 currentJob?.cancel()
68 currentJob = null
92 currentJob = null
93+ currentUrl = null
94+ _state.value = SseState.Disconnected
69 }
95 }
70}
96}
97+
98+/** Live status of the SSE event stream. */
99+sealed interface SseState {
100+ /** Not connected (initial state, or after a clean close). */
101+ data object Disconnected : SseState
102+ /** Connection attempt in progress. */
103+ data object Connecting : SseState
104+ /** Stream is open and events are being received. */
105+ data object Connected : SseState
106+ /** The stream dropped unexpectedly. [message] describes the failure. */
107+ data class Error(val message: String) : SseState
108+}
app/src/main/java/soy/iko/opencode/ui/components/ChatInputBar.ktnew file+102
@@ -0,0 +1,102 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Row
5+import androidx.compose.foundation.layout.Spacer
6+import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.imePadding
8+import androidx.compose.foundation.layout.navigationBarsPadding
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.text.KeyboardActions
12+import androidx.compose.foundation.text.KeyboardOptions
13+import androidx.compose.material.icons.Icons
14+import androidx.compose.material.icons.automirrored.filled.Send
15+import androidx.compose.material3.Icon
16+import androidx.compose.material3.IconButton
17+import androidx.compose.material3.MaterialTheme
18+import androidx.compose.material3.OutlinedTextField
19+import androidx.compose.material3.Surface
20+import androidx.compose.material3.Text
21+import androidx.compose.runtime.Composable
22+import androidx.compose.ui.Alignment
23+import androidx.compose.ui.Modifier
24+import androidx.compose.ui.semantics.contentDescription
25+import androidx.compose.ui.semantics.semantics
26+import androidx.compose.ui.text.input.ImeAction
27+import androidx.compose.ui.tooling.preview.Preview
28+import androidx.compose.ui.unit.dp
29+import soy.iko.opencode.ui.theme.Dimens
30+import soy.iko.opencode.ui.theme.OpencodeTheme
31+
32+/**
33+ * The chat message composer. A multi-line text field plus a send icon button.
34+ *
35+ * - Honors IME insets via [Modifier.imePadding] so the keyboard never covers
36+ * the input bar (the chat Scaffold relies on this).
37+ * - Exposes a keyboard "Send" action so users can fire-and-forget without
38+ * tapping the icon.
39+ * - Send is disabled while [enabled] is false (e.g. during loading) or when
40+ * the text is blank.
41+ */
42+@Composable
43+fun ChatInputBar(
44+ text: String,
45+ onTextChange: (String) -> Unit,
46+ onSend: () -> Unit,
47+ enabled: Boolean,
48+ modifier: Modifier = Modifier,
49+ placeholder: String = "Message…",
50+ sendContentDescription: String = "Send",
51+) {
52+ Surface(
53+ tonalElevation = Dimens.inputBarTonalElevation,
54+ modifier = modifier
55+ .fillMaxWidth()
56+ .imePadding()
57+ .navigationBarsPadding(),
58+ ) {
59+ Row(
60+ verticalAlignment = Alignment.CenterVertically,
61+ horizontalArrangement = Arrangement.spacedBy(Dimens.spaceSmall),
62+ modifier = Modifier
63+ .fillMaxWidth()
64+ .padding(
65+ horizontal = Dimens.inputBarHorizontalPadding,
66+ vertical = Dimens.inputBarVerticalPadding,
67+ ),
68+ ) {
69+ OutlinedTextField(
70+ value = text,
71+ onValueChange = onTextChange,
72+ placeholder = { Text(placeholder) },
73+ modifier = Modifier.weight(1f),
74+ maxLines = 4,
75+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
76+ keyboardActions = KeyboardActions(onSend = { onSend() }),
77+ )
78+ IconButton(
79+ onClick = onSend,
80+ enabled = enabled && text.isNotBlank(),
81+ modifier = Modifier.semantics {
82+ contentDescription = sendContentDescription
83+ },
84+ ) {
85+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
86+ }
87+ }
88+ }
89+}
90+
91+@Preview(showBackground = true)
92+@Composable
93+private fun ChatInputBarPreview() {
94+ OpencodeTheme {
95+ ChatInputBar(
96+ text = "Hello world",
97+ onTextChange = {},
98+ onSend = {},
99+ enabled = true,
100+ )
101+ }
102+}
app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.ktnew file+97
@@ -0,0 +1,97 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Column
4+import androidx.compose.foundation.layout.Spacer
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.height
7+import androidx.compose.foundation.layout.heightIn
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.rememberScrollState
10+import androidx.compose.foundation.verticalScroll
11+import androidx.compose.material3.AlertDialog
12+import androidx.compose.material3.MaterialTheme
13+import androidx.compose.material3.Text
14+import androidx.compose.material3.TextButton
15+import androidx.compose.runtime.Composable
16+import androidx.compose.ui.Modifier
17+import androidx.compose.ui.tooling.preview.Preview
18+import androidx.compose.ui.unit.dp
19+import soy.iko.opencode.ui.theme.Dimens
20+import soy.iko.opencode.ui.theme.OpencodeTheme
21+import java.io.File
22+
23+/**
24+ * Dialog listing persisted crash reports. Each report shows the file name
25+ * (header) and the full stack trace (body). Height wraps content but is
26+ * capped at 60% of the viewport via [Modifier.heightIn] so very long traces
27+ * scroll internally without consuming the whole screen.
28+ */
29+@Composable
30+fun CrashLogDialog(
31+ reports: List<File>,
32+ onDismiss: () -> Unit,
33+ onClear: () -> Unit,
34+ modifier: Modifier = Modifier,
35+ title: String = "Crash Reports",
36+ clearLabel: String = "Clear all",
37+ closeLabel: String = "Close",
38+) {
39+ AlertDialog(
40+ onDismissRequest = onDismiss,
41+ modifier = modifier,
42+ title = { Text(title) },
43+ text = {
44+ Column(
45+ modifier = Modifier
46+ .fillMaxWidth()
47+ .heightIn(max = 360.dp)
48+ .verticalScroll(rememberScrollState()),
49+ ) {
50+ reports.forEachIndexed { index, file ->
51+ Text(
52+ text = file.name,
53+ style = MaterialTheme.typography.labelMedium,
54+ color = MaterialTheme.colorScheme.primary,
55+ )
56+ Spacer(Modifier.height(Dimens.spaceTiny))
57+ Text(
58+ text = file.readText(),
59+ style = MaterialTheme.typography.bodySmall,
60+ )
61+ if (index < reports.lastIndex) {
62+ Spacer(Modifier.height(Dimens.spaceMedium))
63+ }
64+ }
65+ }
66+ },
67+ confirmButton = {
68+ TextButton(onClick = onClear) { Text(clearLabel) }
69+ },
70+ dismissButton = {
71+ TextButton(onClick = onDismiss) { Text(closeLabel) }
72+ },
73+ )
74+}
75+
76+@Preview(showBackground = true)
77+@Composable
78+private fun CrashLogDialogPreview() {
79+ val tmp = File(System.getProperty("java.io.tmpdir"), "crash_preview.txt").apply {
80+ writeText("java.lang.RuntimeException: preview crash\n\tat foo.bar.baz(Preview.kt:1)")
81+ }
82+ OpencodeTheme {
83+ CrashLogDialog(
84+ reports = listOf(tmp),
85+ onDismiss = {},
86+ onClear = {},
87+ )
88+ }
89+}
90+
91+@Preview(showBackground = true)
92+@Composable
93+private fun CrashLogDialogEmptyPreview() {
94+ OpencodeTheme {
95+ CrashLogDialog(reports = emptyList(), onDismiss = {}, onClear = {})
96+ }
97+}
app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.kt+44−4
@@ -11,6 +11,7 @@ import androidx.compose.runtime.LaunchedEffect
11import androidx.compose.runtime.collectAsState
11import androidx.compose.runtime.collectAsState
12import androidx.compose.runtime.getValue
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.remember
13import androidx.compose.runtime.remember
14+import kotlinx.coroutines.flow.SharedFlow
14import soy.iko.opencode.core.Core
15import soy.iko.opencode.core.Core
15import soy.iko.opencode.core.Event
16import soy.iko.opencode.core.Event
16
17
@@ -19,23 +20,35 @@ import soy.iko.opencode.core.Event
19 * the user. Offers a "Dismiss" action that sends [Event.DismissError] to
20 * 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 * clear the error in the core so it doesn't re-show on recomposition.
21 *
22 *
23+ * When the core has a retryable last event, a "Retry" action is also offered
24+ * (it takes precedence over "Dismiss" since it's more useful). Tapping Retry
25+ * re-sends the last user-initiated event.
26+ *
22 * Used by every screen so error messages are never silently dropped.
27 * Used by every screen so error messages are never silently dropped.
23 */
28 */
24@Composable
29@Composable
30+fun ErrorHost(
31+ core: Core,
32+ dismissLabel: String = "Dismiss",
33+ retryLabel: String = "Retry",
25−fun ErrorHost(core: Core): SnackbarHostState {
34+): SnackbarHostState {
26 val view by core.view.collectAsState()
35 val view by core.view.collectAsState()
27 val snackbarHostState = remember { SnackbarHostState() }
36 val snackbarHostState = remember { SnackbarHostState() }
28
37
29 LaunchedEffect(view.error) {
38 LaunchedEffect(view.error) {
30 val message = view.error
39 val message = view.error
31 if (!message.isNullOrEmpty()) {
40 if (!message.isNullOrEmpty()) {
41+ val canRetry = core.retryableEvent != null
32 val result = snackbarHostState.showSnackbar(
42 val result = snackbarHostState.showSnackbar(
33 message = message,
43 message = message,
34− actionLabel = "Dismiss",
44+ actionLabel = if (canRetry) retryLabel else dismissLabel,
35 duration = SnackbarDuration.Long,
45 duration = SnackbarDuration.Long,
36 )
46 )
47+ when (result) {
37− if (result == SnackbarResult.ActionPerformed) {
48+ SnackbarResult.ActionPerformed -> {
49+ if (canRetry) core.retry() else core.update(Event.DismissError)
50+ }
38− core.update(Event.DismissError)
51+ SnackbarResult.Dismissed -> core.update(Event.DismissError)
39 }
52 }
40 }
53 }
41 }
54 }
@@ -43,6 +56,33 @@ fun ErrorHost(core: Core): SnackbarHostState {
43 return snackbarHostState
56 return snackbarHostState
44}
57}
45
58
59+/**
60+ * Collects transient success/info messages from [Core.info] and surfaces them
61+ * as a short snackbar. This gives the user positive feedback (e.g. "Session
62+ * created") instead of only ever showing errors.
63+ */
64+@Composable
65+fun InfoHost(
66+ core: Core,
67+ successConnectedLabel: String,
68+ successSessionCreatedLabel: String,
69+ snackbarHostState: SnackbarHostState,
70+) {
71+ LaunchedEffect(snackbarHostState) {
72+ core.info.collect { key ->
73+ val message = when (key) {
74+ Core.SUCCESS_CONNECTED -> successConnectedLabel
75+ Core.SUCCESS_SESSION_CREATED -> successSessionCreatedLabel
76+ else -> key
77+ }
78+ snackbarHostState.showSnackbar(
79+ message = message,
80+ duration = SnackbarDuration.Short,
81+ )
82+ }
83+ }
84+}
85+
46@Composable
86@Composable
47fun ErrorSnackbarHost(snackbarHostState: SnackbarHostState) {
87fun ErrorSnackbarHost(snackbarHostState: SnackbarHostState) {
48 SnackbarHost(snackbarHostState) { data ->
88 SnackbarHost(snackbarHostState) { data ->
app/src/main/java/soy/iko/opencode/ui/components/LoadingPlaceholder.ktnew file+81
@@ -0,0 +1,81 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.fillMaxSize
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.material3.CircularProgressIndicator
9+import androidx.compose.material3.MaterialTheme
10+import androidx.compose.material3.Text
11+import androidx.compose.runtime.Composable
12+import androidx.compose.ui.Alignment
13+import androidx.compose.ui.Modifier
14+import androidx.compose.ui.semantics.LiveRegionMode
15+import androidx.compose.ui.semantics.contentDescription
16+import androidx.compose.ui.semantics.liveRegion
17+import androidx.compose.ui.semantics.semantics
18+import androidx.compose.ui.text.style.TextAlign
19+import androidx.compose.ui.tooling.preview.Preview
20+import soy.iko.opencode.ui.theme.Dimens
21+import soy.iko.opencode.ui.theme.OpencodeTheme
22+
23+/**
24+ * Centered indeterminate loading indicator. Used when a screen is loading its
25+ * initial content and has nothing else to show. Marked as a polite live region
26+ * so TalkBack announces "loading" once.
27+ */
28+@Composable
29+fun LoadingPlaceholder(
30+ modifier: Modifier = Modifier,
31+ contentDescription: String = "Loading",
32+) {
33+ Box(
34+ modifier = modifier
35+ .fillMaxSize()
36+ .semantics(mergeDescendants = true) {
37+ liveRegion = LiveRegionMode.Polite
38+ this.contentDescription = contentDescription
39+ },
40+ contentAlignment = Alignment.Center,
41+ ) {
42+ CircularProgressIndicator()
43+ }
44+}
45+
46+/**
47+ * Centered empty-state message. Used when a screen has no content yet (e.g.
48+ * no sessions, no messages).
49+ */
50+@Composable
51+fun EmptyState(
52+ message: String,
53+ modifier: Modifier = Modifier,
54+) {
55+ Column(
56+ horizontalAlignment = Alignment.CenterHorizontally,
57+ verticalArrangement = Arrangement.Center,
58+ modifier = modifier
59+ .fillMaxSize()
60+ .padding(Dimens.screenPadding),
61+ ) {
62+ Text(
63+ text = message,
64+ style = MaterialTheme.typography.bodyLarge,
65+ color = MaterialTheme.colorScheme.onSurfaceVariant,
66+ textAlign = TextAlign.Center,
67+ )
68+ }
69+}
70+
71+@Preview(showBackground = true)
72+@Composable
73+private fun LoadingPlaceholderPreview() {
74+ OpencodeTheme { LoadingPlaceholder() }
75+}
76+
77+@Preview(showBackground = true)
78+@Composable
79+private fun EmptyStatePreview() {
80+ OpencodeTheme { EmptyState(message = "No sessions yet") }
81+}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.ktnew file+239
@@ -0,0 +1,239 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.Spacer
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.height
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.layout.widthIn
12+import androidx.compose.material3.CircularProgressIndicator
13+import androidx.compose.material3.MaterialTheme
14+import androidx.compose.material3.Surface
15+import androidx.compose.material3.Text
16+import androidx.compose.runtime.Composable
17+import androidx.compose.ui.Modifier
18+import androidx.compose.ui.platform.LocalDensity
19+import androidx.compose.ui.platform.LocalWindowInfo
20+import androidx.compose.ui.res.stringResource
21+import androidx.compose.ui.semantics.LiveRegionMode
22+import androidx.compose.ui.semantics.contentDescription
23+import androidx.compose.ui.semantics.liveRegion
24+import androidx.compose.ui.semantics.semantics
25+import androidx.compose.ui.text.font.FontStyle
26+import androidx.compose.ui.text.style.TextOverflow
27+import androidx.compose.ui.tooling.preview.Preview
28+import androidx.compose.ui.unit.dp
29+import com.mikepenz.markdown.m3.Markdown
30+import soy.iko.opencode.R
31+import soy.iko.opencode.core.MessageView
32+import soy.iko.opencode.ui.theme.AssistantBubbleShape
33+import soy.iko.opencode.ui.theme.Dimens
34+import soy.iko.opencode.ui.theme.OpencodeTheme
35+import soy.iko.opencode.ui.theme.UserBubbleShape
36+import java.text.SimpleDateFormat
37+import java.util.Date
38+import java.util.Locale
39+
40+/**
41+ * A single chat message rendered as a left/right-aligned bubble. Assistant
42+ * messages are rendered as markdown; user messages as plain text. The bubble
43+ * shape carries an iMessage-style "tail" on the speaker's side.
44+ *
45+ * Accessibility: the whole bubble is merged into one semantics node so
46+ * TalkBack reads the role label + content as a single utterance. Assistant
47+ * bubbles are marked as a [LiveRegionMode.Polite] live region so streaming
48+ * updates are announced without stealing focus.
49+ */
50+@Composable
51+fun MessageBubble(
52+ message: MessageView,
53+ modifier: Modifier = Modifier,
54+) {
55+ val isUser = message.role == "user"
56+ val maxWidth = maxBubbleWidthDp()
57+ val roleLabel = if (isUser) {
58+ stringResource(R.string.chat_role_user)
59+ } else {
60+ stringResource(R.string.chat_role_assistant)
61+ }
62+ Row(
63+ modifier = modifier
64+ .fillMaxWidth()
65+ .semantics(mergeDescendants = true) {
66+ contentDescription = roleLabel
67+ },
68+ horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
69+ ) {
70+ Surface(
71+ color = if (isUser) {
72+ MaterialTheme.colorScheme.primary
73+ } else {
74+ MaterialTheme.colorScheme.surfaceVariant
75+ },
76+ contentColor = if (isUser) {
77+ MaterialTheme.colorScheme.onPrimary
78+ } else {
79+ MaterialTheme.colorScheme.onSurfaceVariant
80+ },
81+ shape = if (isUser) UserBubbleShape else AssistantBubbleShape,
82+ modifier = Modifier.widthIn(max = maxWidth),
83+ ) {
84+ Column(
85+ modifier = Modifier.padding(
86+ horizontal = Dimens.bubbleHorizontalPadding,
87+ vertical = Dimens.bubbleVerticalPadding,
88+ ),
89+ ) {
90+ if (!isUser) {
91+ Row(
92+ modifier = Modifier.fillMaxWidth(),
93+ horizontalArrangement = Arrangement.SpaceBetween,
94+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
95+ ) {
96+ Text(
97+ text = roleLabel,
98+ style = MaterialTheme.typography.labelSmall,
99+ color = MaterialTheme.colorScheme.primary,
100+ )
101+ if (message.time > 0uL) {
102+ Text(
103+ text = formatTimestamp(message.time),
104+ style = MaterialTheme.typography.labelSmall,
105+ color = MaterialTheme.colorScheme.onSurfaceVariant,
106+ )
107+ }
108+ }
109+ Spacer(Modifier.height(Dimens.spaceTiny))
110+ }
111+ if (message.text.isBlank()) {
112+ Text(
113+ text = stringResource(R.string.chat_no_content),
114+ style = MaterialTheme.typography.bodyMedium,
115+ fontStyle = FontStyle.Italic,
116+ )
117+ } else if (isUser) {
118+ Text(
119+ text = message.text,
120+ style = MaterialTheme.typography.bodyMedium,
121+ )
122+ } else {
123+ // Assistant replies often contain markdown (code blocks,
124+ // lists, etc.) — render them properly.
125+ Markdown(
126+ content = message.text,
127+ modifier = Modifier
128+ .fillMaxWidth()
129+ .semantics {
130+ // Polite live region so streaming edits are
131+ // announced without interrupting the user.
132+ liveRegion = LiveRegionMode.Polite
133+ },
134+ )
135+ }
136+ }
137+ }
138+ }
139+}
140+
141+@Composable
142+fun GeneratingIndicator(modifier: Modifier = Modifier) {
143+ val maxWidth = maxBubbleWidthDp()
144+ val generatingLabel = stringResource(R.string.chat_cd_generating)
145+ Row(
146+ horizontalArrangement = Arrangement.Start,
147+ modifier = modifier
148+ .fillMaxWidth()
149+ .padding(vertical = Dimens.spaceTiny),
150+ ) {
151+ Surface(
152+ color = MaterialTheme.colorScheme.surfaceVariant,
153+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
154+ shape = AssistantBubbleShape,
155+ modifier = Modifier.widthIn(max = maxWidth),
156+ ) {
157+ Row(
158+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
159+ modifier = Modifier
160+ .padding(
161+ horizontal = Dimens.bubbleHorizontalPadding,
162+ vertical = Dimens.bubbleVerticalPadding,
163+ )
164+ .semantics(mergeDescendants = true) {
165+ // Announced when the indicator appears/disappears.
166+ liveRegion = LiveRegionMode.Polite
167+ contentDescription = generatingLabel
168+ },
169+ ) {
170+ CircularProgressIndicator(
171+ modifier = Modifier.size(Dimens.iconInlineSpinner),
172+ strokeWidth = Dimens.strokeThin,
173+ )
174+ Spacer(Modifier.size(Dimens.spaceSmall))
175+ Text(
176+ text = stringResource(R.string.chat_generating),
177+ style = MaterialTheme.typography.bodyMedium,
178+ fontStyle = FontStyle.Italic,
179+ maxLines = 1,
180+ overflow = TextOverflow.Ellipsis,
181+ )
182+ }
183+ }
184+ }
185+}
186+
187+private fun formatTimestamp(epochSeconds: ULong): String {
188+ val date = Date(epochSeconds.toLong() * 1000)
189+ val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
190+ return formatter.format(date)
191+}
192+
193+/**
194+ * Max chat-bubble width as a fraction of the window width, in dp. Uses
195+ * [LocalWindowInfo] (the recommended window-size source) rather than the
196+ * deprecated [androidx.compose.ui.platform.LocalConfiguration.screenWidthDp].
197+ */
198+@Composable
199+private fun maxBubbleWidthDp(): androidx.compose.ui.unit.Dp {
200+ val containerSize = LocalWindowInfo.current.containerSize
201+ val density = LocalDensity.current
202+ return with(density) {
203+ containerSize.width.toDp() * Dimens.bubbleMaxWidthFraction
204+ }
205+}
206+
207+@Preview(showBackground = true)
208+@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
209+@Composable
210+private fun MessageBubbleUserPreview() {
211+ OpencodeTheme {
212+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
213+ MessageBubble(
214+ message = MessageView(
215+ id = "u1",
216+ role = "user",
217+ text = "Hello! Can you summarize the build steps?",
218+ time = 1_700_000_000uL,
219+ ),
220+ )
221+ MessageBubble(
222+ message = MessageView(
223+ id = "a1",
224+ role = "assistant",
225+ text = "Sure — here's a **short** list:\n\n1. Generate types\n2. Build the native library\n3. Assemble the APK",
226+ time = 1_700_000_010uL,
227+ ),
228+ )
229+ }
230+ }
231+}
232+
233+@Preview(showBackground = true)
234+@Composable
235+private fun GeneratingIndicatorPreview() {
236+ OpencodeTheme {
237+ GeneratingIndicator()
238+ }
239+}
app/src/main/java/soy/iko/opencode/ui/components/SseDisconnectedBanner.ktnew file+88
@@ -0,0 +1,88 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Row
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.padding
7+import androidx.compose.material.icons.Icons
8+import androidx.compose.material.icons.filled.CloudOff
9+import androidx.compose.material3.Icon
10+import androidx.compose.material3.MaterialTheme
11+import androidx.compose.material3.Surface
12+import androidx.compose.material3.Text
13+import androidx.compose.material3.TextButton
14+import androidx.compose.runtime.Composable
15+import androidx.compose.ui.Alignment
16+import androidx.compose.ui.Modifier
17+import androidx.compose.ui.semantics.LiveRegionMode
18+import androidx.compose.ui.semantics.contentDescription
19+import androidx.compose.ui.semantics.liveRegion
20+import androidx.compose.ui.semantics.semantics
21+import androidx.compose.ui.tooling.preview.Preview
22+import androidx.compose.ui.unit.dp
23+import soy.iko.opencode.core.SseState
24+import soy.iko.opencode.ui.theme.Dimens
25+import soy.iko.opencode.ui.theme.OpencodeTheme
26+
27+/**
28+ * A thin banner shown above the message list when the SSE live stream drops.
29+ * Offers a "Reconnect" action. The banner is a polite live region so screen
30+ * readers announce the disconnection once.
31+ *
32+ * Only render this when [SseState] is [SseState.Error] or an unexpected
33+ * [SseState.Disconnected] while a chat is active.
34+ */
35+@Composable
36+fun SseDisconnectedBanner(
37+ message: String,
38+ onReconnect: () -> Unit,
39+ modifier: Modifier = Modifier,
40+ reconnectLabel: String = "Reconnect",
41+) {
42+ Surface(
43+ color = MaterialTheme.colorScheme.errorContainer,
44+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
45+ modifier = modifier
46+ .fillMaxWidth()
47+ .semantics(mergeDescendants = true) {
48+ liveRegion = LiveRegionMode.Polite
49+ contentDescription = message
50+ },
51+ ) {
52+ Row(
53+ verticalAlignment = Alignment.CenterVertically,
54+ horizontalArrangement = Arrangement.SpaceBetween,
55+ modifier = Modifier
56+ .fillMaxWidth()
57+ .padding(
58+ horizontal = Dimens.spaceLarge,
59+ vertical = Dimens.spaceSmall,
60+ ),
61+ ) {
62+ Row(
63+ verticalAlignment = Alignment.CenterVertically,
64+ horizontalArrangement = Arrangement.spacedBy(Dimens.spaceSmall),
65+ modifier = Modifier.weight(1f),
66+ ) {
67+ Icon(Icons.Default.CloudOff, contentDescription = null)
68+ Text(
69+ text = message,
70+ style = MaterialTheme.typography.bodySmall,
71+ maxLines = 2,
72+ )
73+ }
74+ TextButton(onClick = onReconnect) { Text(reconnectLabel) }
75+ }
76+ }
77+}
78+
79+@Preview(showBackground = true)
80+@Composable
81+private fun SseDisconnectedBannerPreview() {
82+ OpencodeTheme {
83+ SseDisconnectedBanner(
84+ message = "Live stream disconnected",
85+ onReconnect = {},
86+ )
87+ }
88+}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+152−189
@@ -5,29 +5,18 @@ import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
7import androidx.compose.foundation.layout.PaddingValues
8−import androidx.compose.foundation.layout.Row
9−import androidx.compose.foundation.layout.Spacer
10import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxSize
11−import androidx.compose.foundation.layout.fillMaxWidth
12−import androidx.compose.foundation.layout.height
13import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
14−import androidx.compose.foundation.layout.size
15−import androidx.compose.foundation.layout.widthIn
16import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.items
11import androidx.compose.foundation.lazy.items
18import androidx.compose.foundation.lazy.rememberLazyListState
12import androidx.compose.foundation.lazy.rememberLazyListState
19−import androidx.compose.foundation.shape.RoundedCornerShape
20import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
14import androidx.compose.material.icons.automirrored.filled.ArrowBack
22−import androidx.compose.material.icons.automirrored.filled.Send
23−import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
15import androidx.compose.material3.ExperimentalMaterial3Api
25import androidx.compose.material3.Icon
16import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
17import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.MaterialTheme
28−import androidx.compose.material3.OutlinedTextField
29import androidx.compose.material3.Scaffold
19import androidx.compose.material3.Scaffold
30−import androidx.compose.material3.Surface
31import androidx.compose.material3.Text
20import androidx.compose.material3.Text
32import androidx.compose.material3.TopAppBar
21import androidx.compose.material3.TopAppBar
33import androidx.compose.runtime.Composable
22import androidx.compose.runtime.Composable
@@ -38,26 +27,33 @@ import androidx.compose.runtime.getValue
38import androidx.compose.runtime.mutableStateOf
27import androidx.compose.runtime.mutableStateOf
39import androidx.compose.runtime.remember
28import androidx.compose.runtime.remember
40import androidx.compose.runtime.setValue
29import androidx.compose.runtime.setValue
41−import androidx.compose.ui.Alignment
42import androidx.compose.ui.Modifier
30import androidx.compose.ui.Modifier
43−import androidx.compose.ui.platform.LocalConfiguration
31+import androidx.compose.ui.res.stringResource
44−import androidx.compose.ui.text.font.FontStyle
45import androidx.compose.ui.text.style.TextOverflow
32import androidx.compose.ui.text.style.TextOverflow
33+import androidx.compose.ui.tooling.preview.Preview
46import androidx.compose.ui.unit.dp
34import androidx.compose.ui.unit.dp
47−import com.mikepenz.markdown.m3.Markdown
35+import soy.iko.opencode.R
48import soy.iko.opencode.core.Core
36import soy.iko.opencode.core.Core
49import soy.iko.opencode.core.Event
37import soy.iko.opencode.core.Event
50import soy.iko.opencode.core.MessageView
38import soy.iko.opencode.core.MessageView
39+import soy.iko.opencode.core.SseState
40+import soy.iko.opencode.ui.components.ChatInputBar
41+import soy.iko.opencode.ui.components.EmptyState
51import soy.iko.opencode.ui.components.ErrorHost
42import soy.iko.opencode.ui.components.ErrorHost
52import soy.iko.opencode.ui.components.ErrorSnackbarHost
43import soy.iko.opencode.ui.components.ErrorSnackbarHost
53−import java.text.SimpleDateFormat
44+import soy.iko.opencode.ui.components.GeneratingIndicator
54−import java.util.Date
45+import soy.iko.opencode.ui.components.InfoHost
55−import java.util.Locale
46+import soy.iko.opencode.ui.components.LoadingPlaceholder
47+import soy.iko.opencode.ui.components.MessageBubble
48+import soy.iko.opencode.ui.components.SseDisconnectedBanner
49+import soy.iko.opencode.ui.theme.Dimens
50+import soy.iko.opencode.ui.theme.OpencodeTheme
56
51
57@OptIn(ExperimentalMaterial3Api::class)
52@OptIn(ExperimentalMaterial3Api::class)
58@Composable
53@Composable
59fun ChatScreen(core: Core) {
54fun ChatScreen(core: Core) {
60 val view by core.view.collectAsState()
55 val view by core.view.collectAsState()
56+ val sseState by core.sseState.collectAsState()
61 var inputText by remember { mutableStateOf("") }
57 var inputText by remember { mutableStateOf("") }
62 val listState = rememberLazyListState()
58 val listState = rememberLazyListState()
63
59
@@ -77,6 +73,21 @@ fun ChatScreen(core: Core) {
77 }
73 }
78 }
74 }
79
75
76+ val snackbarHostState = ErrorHost(
77+ core = core,
78+ dismissLabel = stringResource(R.string.action_dismiss),
79+ retryLabel = stringResource(R.string.action_retry),
80+ )
81+ InfoHost(
82+ core = core,
83+ successConnectedLabel = stringResource(R.string.connect_success),
84+ successSessionCreatedLabel = stringResource(R.string.session_created),
85+ snackbarHostState = snackbarHostState,
86+ )
87+
88+ val showSseBanner = sseState is SseState.Error ||
89+ (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
90+
80 Scaffold(
91 Scaffold(
81 topBar = {
92 topBar = {
82 TopAppBar(
93 TopAppBar(
@@ -84,7 +95,8 @@ fun ChatScreen(core: Core) {
84 Column {
95 Column {
85 Text(
96 Text(
86 text = view.currentSessionTitle.ifEmpty {
97 text = view.currentSessionTitle.ifEmpty {
87− view.currentSessionId?.take(8)?.plus("…") ?: "Chat"
98+ view.currentSessionId?.take(8)?.plus("…")
99+ ?: stringResource(R.string.top_bar_chat_fallback)
88 },
100 },
89 maxLines = 1,
101 maxLines = 1,
90 overflow = TextOverflow.Ellipsis,
102 overflow = TextOverflow.Ellipsis,
@@ -101,7 +113,10 @@ fun ChatScreen(core: Core) {
101 },
113 },
102 navigationIcon = {
114 navigationIcon = {
103 IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
115 IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
116+ Icon(
104− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
117+ Icons.AutoMirrored.Filled.ArrowBack,
118+ contentDescription = stringResource(R.string.chat_cd_back),
119+ )
105 }
120 }
106 },
121 },
107 )
122 )
@@ -117,46 +132,54 @@ fun ChatScreen(core: Core) {
117 }
132 }
118 },
133 },
119 enabled = !view.loading,
134 enabled = !view.loading,
135+ placeholder = stringResource(R.string.chat_input_placeholder),
136+ sendContentDescription = stringResource(R.string.chat_cd_send),
120 )
137 )
121 },
138 },
122− snackbarHost = { ErrorSnackbarHost(ErrorHost(core)) },
139+ snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
123 ) { padding ->
140 ) { padding ->
124 Box(
141 Box(
125 modifier = Modifier
142 modifier = Modifier
126 .fillMaxSize()
143 .fillMaxSize()
127 .padding(padding),
144 .padding(padding),
128 ) {
145 ) {
129− if (view.loading && view.messages.isEmpty()) {
130− CircularProgressIndicator(
131− modifier = Modifier.align(Alignment.Center),
132− )
133− } else if (view.messages.isEmpty()) {
134− Column(
135− horizontalAlignment = Alignment.CenterHorizontally,
136− verticalArrangement = Arrangement.Center,
137− modifier = Modifier.fillMaxSize().padding(24.dp),
146+ Column(modifier = Modifier.fillMaxSize()) {
138− ) {
147+ if (showSseBanner) {
139− Text(
148+ val bannerMessage = (sseState as? SseState.Error)?.message
140− "Send a message to start chatting",
149+ ?: stringResource(R.string.sse_disconnected)
141− style = MaterialTheme.typography.bodyLarge,
150+ SseDisconnectedBanner(
142− color = MaterialTheme.colorScheme.onSurfaceVariant,
151+ message = bannerMessage,
152+ onReconnect = { core.reconnectSse() },
153+ reconnectLabel = stringResource(R.string.sse_reconnect),
143 )
154 )
144 }
155 }
145− } else {
156+
146− LazyColumn(
157+ when {
147− state = listState,
158+ view.loading && view.messages.isEmpty() -> {
148− modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
159+ LoadingPlaceholder()
149− verticalArrangement = Arrangement.spacedBy(8.dp),
150− contentPadding = PaddingValues(vertical = 12.dp),
151− ) {
152− items(view.messages, key = { it.id }) { message ->
153− MessageBubble(message)
154 }
160 }
161+ view.messages.isEmpty() -> {
162+ EmptyState(message = stringResource(R.string.chat_empty))
163+ }
164+ else -> {
165+ LazyColumn(
166+ state = listState,
167+ modifier = Modifier
168+ .fillMaxSize()
169+ .padding(horizontal = Dimens.listHorizontalPadding),
170+ verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
171+ contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
172+ ) {
173+ items(view.messages, key = { it.id }) { message ->
174+ MessageBubble(message)
175+ }
155− // "Thinking" indicator shown after the user sends, while
176+ // "Thinking" indicator shown after the user sends, while
156− // the assistant reply hasn't arrived yet.
177+ // the assistant reply hasn't arrived yet.
157− item(key = "__generating__") {
178+ item(key = "__generating__") {
158− AnimatedVisibility(visible = view.generating) {
179+ AnimatedVisibility(visible = view.generating) {
159− GeneratingIndicator()
180+ GeneratingIndicator()
181+ }
182+ }
160 }
183 }
161 }
184 }
162 }
185 }
@@ -165,159 +188,99 @@ fun ChatScreen(core: Core) {
165 }
188 }
166}
189}
167
190
191+@OptIn(ExperimentalMaterial3Api::class)
192+@Preview(showBackground = true)
193+@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
168@Composable
194@Composable
169−private fun MessageBubble(message: MessageView) {
195+private fun ChatScreenPreview() {
170− val isUser = message.role == "user"
196+ OpencodeTheme {
171− val maxWidth = LocalConfiguration.current.screenWidthDp.dp * 0.82f
197+ PreviewChatScreen(
172− Row(
198+ title = "Refactor the parser",
173− modifier = Modifier.fillMaxWidth(),
199+ sessionId = "abc12345",
174− horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
200+ messages = listOf(
175− ) {
201+ MessageView("u1", "user", "Can you clean up parse_messages?", 1_700_000_000uL),
176− Surface(
202+ MessageView(
177− color = if (isUser) {
203+ "a1",
178− MaterialTheme.colorScheme.primary
204+ "assistant",
179− } else {
205+ "Sure — here's a **plan**:\n\n1. Extract helpers\n2. Add tests",
180− MaterialTheme.colorScheme.surfaceVariant
206+ 1_700_000_010uL,
181− },
207+ ),
182− contentColor = if (isUser) {
183− MaterialTheme.colorScheme.onPrimary
184− } else {
185− MaterialTheme.colorScheme.onSurfaceVariant
186− },
187− shape = RoundedCornerShape(
188− topStart = 16.dp,
189− topEnd = 16.dp,
190− bottomStart = if (isUser) 16.dp else 4.dp,
191− bottomEnd = if (isUser) 4.dp else 16.dp,
192 ),
208 ),
193− modifier = Modifier.widthIn(max = maxWidth),
209+ generating = false,
194− ) {
210+ )
195− Column(
196− modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
197− ) {
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()) {
220− Text(
221− text = "(no content)",
222− style = MaterialTheme.typography.bodyMedium,
223− fontStyle = FontStyle.Italic,
224− )
225− } else if (isUser) {
226− Text(
227− text = message.text,
228− style = MaterialTheme.typography.bodyMedium,
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(),
236− )
237− }
238− }
239− }
240 }
211 }
241}
212}
242
213
214+@OptIn(ExperimentalMaterial3Api::class)
215+@Preview(showBackground = true)
243@Composable
216@Composable
244−private fun GeneratingIndicator() {
217+private fun ChatScreenGeneratingPreview() {
245− Row(
218+ OpencodeTheme {
246− horizontalArrangement = Arrangement.Start,
219+ PreviewChatScreen(
247− modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
220+ title = "New session",
248− ) {
221+ sessionId = "xyz12345",
249− Surface(
222+ messages = listOf(MessageView("u1", "user", "Hello!", 1_700_000_000uL)),
250− color = MaterialTheme.colorScheme.surfaceVariant,
223+ generating = true,
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− ) {
224+ )
260− Row(
225+ }
261− verticalAlignment = Alignment.CenterVertically,
226+}
262− modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
227+
263− ) {
228+@OptIn(ExperimentalMaterial3Api::class)
264− CircularProgressIndicator(
229+@Preview(showBackground = true)
265− modifier = Modifier.size(14.dp),
230+@Composable
266− strokeWidth = 2.dp,
231+private fun ChatScreenEmptyPreview() {
267− )
232+ OpencodeTheme {
268− Spacer(Modifier.size(8.dp))
233+ PreviewChatScreen(title = "", sessionId = null, messages = emptyList(), generating = false)
269− Text(
270− text = "Generating…",
271− style = MaterialTheme.typography.bodyMedium,
272− fontStyle = FontStyle.Italic,
273− )
274− }
275− }
276 }
234 }
277}
235}
278
236
237+@OptIn(ExperimentalMaterial3Api::class)
279@Composable
238@Composable
280−private fun ChatInputBar(
239+private fun PreviewChatScreen(
281− text: String,
240+ title: String,
282− onTextChange: (String) -> Unit,
241+ sessionId: String?,
283− onSend: () -> Unit,
242+ messages: List<MessageView>,
284− enabled: Boolean,
243+ generating: Boolean,
285) {
244) {
286− Surface(
245+ Scaffold(
287− tonalElevation = 3.dp,
246+ topBar = {
288− modifier = Modifier.fillMaxWidth(),
247+ TopAppBar(
289− ) {
248+ title = {
290− Row(
249+ Text(
291− verticalAlignment = Alignment.CenterVertically,
250+ text = title.ifEmpty {
292− modifier = Modifier
251+ sessionId?.take(8)?.plus("…") ?: "Chat"
293− .fillMaxWidth()
252+ },
294− .padding(horizontal = 12.dp, vertical = 8.dp),
295− ) {
296− OutlinedTextField(
297− value = text,
298− onValueChange = onTextChange,
299− placeholder = { Text("Message…") },
300− modifier = Modifier.weight(1f),
301− maxLines = 4,
253+ maxLines = 1,
254+ overflow = TextOverflow.Ellipsis,
255+ )
256+ },
257+ navigationIcon = {
258+ IconButton(onClick = {}) {
259+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
260+ }
261+ },
302 )
262 )
303− Spacer(Modifier.size(8.dp))
263+ },
304− // Rely on the standard disabled styling instead of overriding
264+ bottomBar = {
305− // the tint manually.
265+ ChatInputBar(text = "", onTextChange = {}, onSend = {}, enabled = true)
306− IconButton(
266+ },
307− onClick = onSend,
267+ ) { padding ->
308− enabled = enabled && text.isNotBlank(),
268+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
309− ) {
269+ when {
310− Icon(
270+ messages.isEmpty() -> EmptyState(message = "Send a message to start chatting")
311− Icons.AutoMirrored.Filled.Send,
271+ else -> {
312− contentDescription = "Send",
272+ LazyColumn(
313− )
273+ modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
274+ verticalArrangement = Arrangement.spacedBy(8.dp),
275+ contentPadding = PaddingValues(vertical = 12.dp),
276+ ) {
277+ items(messages, key = { it.id }) { MessageBubble(it) }
278+ if (generating) {
279+ item(key = "__generating__") { GeneratingIndicator() }
280+ }
281+ }
282+ }
314 }
283 }
315 }
284 }
316 }
285 }
317}
286}
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+99−67
@@ -20,7 +20,6 @@ import androidx.compose.material.icons.filled.Lock
20import androidx.compose.material.icons.filled.Visibility
20import androidx.compose.material.icons.filled.Visibility
21import androidx.compose.material.icons.filled.VisibilityOff
21import androidx.compose.material.icons.filled.VisibilityOff
22import androidx.compose.material.icons.filled.Warning
22import androidx.compose.material.icons.filled.Warning
23−import androidx.compose.material3.AlertDialog
24import androidx.compose.material3.Button
23import androidx.compose.material3.Button
25import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.CircularProgressIndicator
26import androidx.compose.material3.ExperimentalMaterial3Api
25import androidx.compose.material3.ExperimentalMaterial3Api
@@ -33,6 +32,7 @@ import androidx.compose.material3.Text
33import androidx.compose.material3.TextButton
32import androidx.compose.material3.TextButton
34import androidx.compose.material3.TopAppBar
33import androidx.compose.material3.TopAppBar
35import androidx.compose.runtime.Composable
34import androidx.compose.runtime.Composable
35+import androidx.compose.runtime.LaunchedEffect
36import androidx.compose.runtime.collectAsState
36import androidx.compose.runtime.collectAsState
37import androidx.compose.runtime.getValue
37import androidx.compose.runtime.getValue
38import androidx.compose.runtime.mutableStateOf
38import androidx.compose.runtime.mutableStateOf
@@ -40,17 +40,25 @@ import androidx.compose.runtime.remember
40import androidx.compose.runtime.setValue
40import androidx.compose.runtime.setValue
41import androidx.compose.ui.Alignment
41import androidx.compose.ui.Alignment
42import androidx.compose.ui.Modifier
42import androidx.compose.ui.Modifier
43+import androidx.compose.ui.focus.FocusRequester
44+import androidx.compose.ui.focus.focusRequester
45+import androidx.compose.ui.res.pluralStringResource
46+import androidx.compose.ui.res.stringResource
43import androidx.compose.ui.text.input.ImeAction
47import androidx.compose.ui.text.input.ImeAction
44import androidx.compose.ui.text.input.KeyboardType
48import androidx.compose.ui.text.input.KeyboardType
45import androidx.compose.ui.text.input.PasswordVisualTransformation
49import androidx.compose.ui.text.input.PasswordVisualTransformation
46import androidx.compose.ui.text.input.VisualTransformation
50import androidx.compose.ui.text.input.VisualTransformation
51+import androidx.compose.ui.tooling.preview.Preview
47import androidx.compose.ui.unit.dp
52import androidx.compose.ui.unit.dp
48import soy.iko.opencode.CrashLogger
53import soy.iko.opencode.CrashLogger
54+import soy.iko.opencode.R
49import soy.iko.opencode.core.Core
55import soy.iko.opencode.core.Core
50import soy.iko.opencode.core.Event
56import soy.iko.opencode.core.Event
57+import soy.iko.opencode.ui.components.CrashLogDialog
51import soy.iko.opencode.ui.components.ErrorHost
58import soy.iko.opencode.ui.components.ErrorHost
52import soy.iko.opencode.ui.components.ErrorSnackbarHost
59import soy.iko.opencode.ui.components.ErrorSnackbarHost
53−import java.io.File
60+import soy.iko.opencode.ui.components.InfoHost
61+import soy.iko.opencode.ui.theme.Dimens
54
62
55@OptIn(ExperimentalMaterial3Api::class)
63@OptIn(ExperimentalMaterial3Api::class)
56@Composable
64@Composable
@@ -61,7 +69,26 @@ fun ConnectScreen(core: Core) {
61 var password by remember(view.password) { mutableStateOf(view.password) }
69 var password by remember(view.password) { mutableStateOf(view.password) }
62 var passwordVisible by remember { mutableStateOf(false) }
70 var passwordVisible by remember { mutableStateOf(false) }
63 var showCrashLogs by remember { mutableStateOf(false) }
71 var showCrashLogs by remember { mutableStateOf(false) }
64− val snackbarHostState = ErrorHost(core)
72+ val snackbarHostState = ErrorHost(
73+ core = core,
74+ dismissLabel = stringResource(R.string.action_dismiss),
75+ retryLabel = stringResource(R.string.action_retry),
76+ )
77+ InfoHost(
78+ core = core,
79+ successConnectedLabel = stringResource(R.string.connect_success),
80+ successSessionCreatedLabel = stringResource(R.string.session_created),
81+ snackbarHostState = snackbarHostState,
82+ )
83+
84+ // Auto-focus the username field when the server reveals it requires
85+ // authentication, so the user doesn't have to tap into it manually.
86+ val usernameFocusRequester = remember { FocusRequester() }
87+ LaunchedEffect(view.authRequired) {
88+ if (view.authRequired) {
89+ usernameFocusRequester.requestFocus()
90+ }
91+ }
65
92
66 fun submit() {
93 fun submit() {
67 core.update(Event.ServerUrlChanged(url))
94 core.update(Event.ServerUrlChanged(url))
@@ -74,7 +101,7 @@ fun ConnectScreen(core: Core) {
74
101
75 Scaffold(
102 Scaffold(
76 topBar = {
103 topBar = {
77− TopAppBar(title = { Text("Opencode") })
104+ TopAppBar(title = { Text(stringResource(R.string.top_bar_connect)) })
78 },
105 },
79 snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
106 snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
80 ) { padding ->
107 ) { padding ->
@@ -84,25 +111,25 @@ fun ConnectScreen(core: Core) {
84 modifier = Modifier
111 modifier = Modifier
85 .fillMaxSize()
112 .fillMaxSize()
86 .padding(padding)
113 .padding(padding)
87− .padding(24.dp)
114+ .padding(Dimens.screenPadding)
88 .verticalScroll(rememberScrollState()),
115 .verticalScroll(rememberScrollState()),
89 ) {
116 ) {
90 Icon(
117 Icon(
91 imageVector = Icons.Default.Dns,
118 imageVector = Icons.Default.Dns,
92− contentDescription = null,
119+ contentDescription = stringResource(R.string.connect_cd_dns),
93− modifier = Modifier.size(72.dp),
120+ modifier = Modifier.size(Dimens.iconHero),
94 tint = MaterialTheme.colorScheme.primary,
121 tint = MaterialTheme.colorScheme.primary,
95 )
122 )
96− Spacer(Modifier.height(16.dp))
123+ Spacer(Modifier.height(Dimens.spaceLarge))
97 Text(
124 Text(
98− text = "Connect to Opencode Server",
125+ text = stringResource(R.string.connect_heading),
99 style = MaterialTheme.typography.headlineSmall,
126 style = MaterialTheme.typography.headlineSmall,
100 )
127 )
101− Spacer(Modifier.height(24.dp))
128+ Spacer(Modifier.height(Dimens.spaceXLarge))
102 OutlinedTextField(
129 OutlinedTextField(
103 value = url,
130 value = url,
104 onValueChange = { url = it },
131 onValueChange = { url = it },
105− label = { Text("Server URL") },
132+ label = { Text(stringResource(R.string.connect_field_server_url)) },
106 singleLine = true,
133 singleLine = true,
107 keyboardOptions = KeyboardOptions(
134 keyboardOptions = KeyboardOptions(
108 keyboardType = KeyboardType.Uri,
135 keyboardType = KeyboardType.Uri,
@@ -115,32 +142,35 @@ fun ConnectScreen(core: Core) {
115 AnimatedVisibility(visible = view.authRequired) {
142 AnimatedVisibility(visible = view.authRequired) {
116 Column(
143 Column(
117 horizontalAlignment = Alignment.CenterHorizontally,
144 horizontalAlignment = Alignment.CenterHorizontally,
118− modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
145+ modifier = Modifier.fillMaxWidth().padding(top = Dimens.spaceSmall),
119 ) {
146 ) {
120 Text(
147 Text(
121− text = "Server requires authentication",
148+ text = stringResource(R.string.connect_auth_required),
122 style = MaterialTheme.typography.bodyMedium,
149 style = MaterialTheme.typography.bodyMedium,
123 color = MaterialTheme.colorScheme.error,
150 color = MaterialTheme.colorScheme.error,
124 )
151 )
125− Spacer(Modifier.height(8.dp))
152+ Spacer(Modifier.height(Dimens.spaceSmall))
126 OutlinedTextField(
153 OutlinedTextField(
127 value = username,
154 value = username,
128 onValueChange = { username = it },
155 onValueChange = { username = it },
129− label = { Text("Username") },
156+ label = { Text(stringResource(R.string.connect_field_username)) },
130 singleLine = true,
157 singleLine = true,
131 leadingIcon = {
158 leadingIcon = {
159+ Icon(
132− Icon(Icons.Default.Lock, contentDescription = null)
160+ Icons.Default.Lock,
161+ contentDescription = stringResource(R.string.connect_cd_lock),
162+ )
133 },
163 },
134− keyboardOptions = KeyboardOptions(
164+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
135− imeAction = ImeAction.Next,
136− ),
137− modifier = Modifier.fillMaxWidth(),
165+ modifier = Modifier
166+ .fillMaxWidth()
167+ .focusRequester(usernameFocusRequester),
138 )
168 )
139− Spacer(Modifier.height(8.dp))
169+ Spacer(Modifier.height(Dimens.spaceSmall))
140 OutlinedTextField(
170 OutlinedTextField(
141 value = password,
171 value = password,
142 onValueChange = { password = it },
172 onValueChange = { password = it },
143− label = { Text("Password") },
173+ label = { Text(stringResource(R.string.connect_field_password)) },
144 singleLine = true,
174 singleLine = true,
145 visualTransformation = if (passwordVisible) {
175 visualTransformation = if (passwordVisible) {
146 VisualTransformation.None
176 VisualTransformation.None
@@ -156,9 +186,9 @@ fun ConnectScreen(core: Core) {
156 Icons.Default.Visibility
186 Icons.Default.Visibility
157 },
187 },
158 contentDescription = if (passwordVisible) {
188 contentDescription = if (passwordVisible) {
159− "Hide password"
189+ stringResource(R.string.connect_password_hide)
160 } else {
190 } else {
161− "Show password"
191+ stringResource(R.string.connect_password_show)
162 },
192 },
163 )
193 )
164 }
194 }
@@ -170,14 +200,14 @@ fun ConnectScreen(core: Core) {
170 keyboardActions = KeyboardActions(onGo = { submit() }),
200 keyboardActions = KeyboardActions(onGo = { submit() }),
171 modifier = Modifier.fillMaxWidth(),
201 modifier = Modifier.fillMaxWidth(),
172 )
202 )
173− Spacer(Modifier.height(4.dp))
203+ Spacer(Modifier.height(Dimens.spaceTiny))
174 TextButton(onClick = { core.update(Event.CancelAuth) }) {
204 TextButton(onClick = { core.update(Event.CancelAuth) }) {
175− Text("Cancel")
205+ Text(stringResource(R.string.connect_button_cancel))
176 }
206 }
177 }
207 }
178 }
208 }
179
209
180− Spacer(Modifier.height(16.dp))
210+ Spacer(Modifier.height(Dimens.spaceLarge))
181 Button(
211 Button(
182 onClick = { submit() },
212 onClick = { submit() },
183 enabled = !view.loading,
213 enabled = !view.loading,
@@ -185,29 +215,29 @@ fun ConnectScreen(core: Core) {
185 ) {
215 ) {
186 if (view.loading) {
216 if (view.loading) {
187 CircularProgressIndicator(
217 CircularProgressIndicator(
188− modifier = Modifier.size(20.dp),
218+ modifier = Modifier.size(Dimens.iconButtonSpinner),
189− strokeWidth = 2.dp,
219+ strokeWidth = Dimens.strokeThin,
190 color = MaterialTheme.colorScheme.onPrimary,
220 color = MaterialTheme.colorScheme.onPrimary,
191 )
221 )
192 } else {
222 } else {
193− Text("Connect")
223+ Text(stringResource(R.string.connect_button_connect))
194 }
224 }
195 }
225 }
196
226
197 val crashCount = CrashLogger.getReports().size
227 val crashCount = CrashLogger.getReports().size
198 if (crashCount > 0) {
228 if (crashCount > 0) {
199− Spacer(Modifier.height(16.dp))
229+ Spacer(Modifier.height(Dimens.spaceLarge))
200 Row(verticalAlignment = Alignment.CenterVertically) {
230 Row(verticalAlignment = Alignment.CenterVertically) {
201 Icon(
231 Icon(
202 Icons.Default.Warning,
232 Icons.Default.Warning,
203− contentDescription = null,
233+ contentDescription = stringResource(R.string.connect_cd_warning),
204 tint = MaterialTheme.colorScheme.error,
234 tint = MaterialTheme.colorScheme.error,
205− modifier = Modifier.size(16.dp),
235+ modifier = Modifier.size(Dimens.iconInputLeading),
206 )
236 )
207 Spacer(Modifier.size(6.dp))
237 Spacer(Modifier.size(6.dp))
208 TextButton(onClick = { showCrashLogs = true }) {
238 TextButton(onClick = { showCrashLogs = true }) {
209 Text(
239 Text(
210− text = "$crashCount crash report(s) — view",
240+ text = pluralStringResource(R.plurals.crash_reports_view, crashCount, crashCount),
211 style = MaterialTheme.typography.labelSmall,
241 style = MaterialTheme.typography.labelSmall,
212 )
242 )
213 }
243 }
@@ -224,48 +254,50 @@ fun ConnectScreen(core: Core) {
224 CrashLogger.clearReports()
254 CrashLogger.clearReports()
225 showCrashLogs = false
255 showCrashLogs = false
226 },
256 },
257+ title = stringResource(R.string.crash_reports_title),
258+ clearLabel = stringResource(R.string.crash_reports_clear),
259+ closeLabel = stringResource(R.string.crash_reports_close),
227 )
260 )
228 }
261 }
229}
262}
230
263
264+@OptIn(ExperimentalMaterial3Api::class)
265+@Preview(showBackground = true)
266+@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
231@Composable
267@Composable
232−private fun CrashLogDialog(
268+private fun ConnectScreenPreview() {
233− reports: List<File>,
269+ soy.iko.opencode.ui.theme.OpencodeTheme {
234− onDismiss: () -> Unit,
270+ Scaffold(topBar = { TopAppBar(title = { Text("Opencode") }) }) { padding ->
235− onClear: () -> Unit,
236−) {
237− AlertDialog(
238− onDismissRequest = onDismiss,
239− title = { Text("Crash Reports") },
240− text = {
241 Column(
271 Column(
272+ horizontalAlignment = Alignment.CenterHorizontally,
273+ verticalArrangement = Arrangement.Center,
242 modifier = Modifier
274 modifier = Modifier
243− .fillMaxWidth()
275+ .fillMaxSize()
244− .height(360.dp)
276+ .padding(padding)
277+ .padding(24.dp)
245 .verticalScroll(rememberScrollState()),
278 .verticalScroll(rememberScrollState()),
246 ) {
279 ) {
247− reports.forEachIndexed { index, file ->
280+ Icon(
248− Text(
281+ Icons.Default.Dns,
249− text = file.name,
282+ contentDescription = null,
250− style = MaterialTheme.typography.labelMedium,
283+ modifier = Modifier.size(72.dp),
251− color = MaterialTheme.colorScheme.primary,
284+ tint = MaterialTheme.colorScheme.primary,
252− )
285+ )
253− Spacer(Modifier.height(4.dp))
286+ Spacer(Modifier.height(16.dp))
254− Text(
287+ Text("Connect to Opencode Server", style = MaterialTheme.typography.headlineSmall)
255− text = file.readText(),
288+ Spacer(Modifier.height(24.dp))
256− style = MaterialTheme.typography.bodySmall,
289+ OutlinedTextField(
257− )
290+ value = "http://localhost:4096",
258− if (index < reports.lastIndex) {
291+ onValueChange = {},
292+ label = { Text("Server URL") },
293+ singleLine = true,
294+ modifier = Modifier.fillMaxWidth(),
295+ )
259− Spacer(Modifier.height(12.dp))
296+ Spacer(Modifier.height(16.dp))
260− }
297+ Button(onClick = {}, modifier = Modifier.fillMaxWidth()) {
298+ Text("Connect")
261 }
299 }
262 }
300 }
263− },
301+ }
264− confirmButton = {
302+ }
265− TextButton(onClick = onClear) { Text("Clear all") }
266− },
267− dismissButton = {
268− TextButton(onClick = onDismiss) { Text("Close") }
269− },
270− )
271}
303}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+143−52
@@ -1,9 +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
5import androidx.compose.foundation.layout.Box
4import androidx.compose.foundation.layout.Box
6−import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.fillMaxSize
5import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.lazy.LazyColumn
7import androidx.compose.foundation.lazy.LazyColumn
@@ -12,50 +10,82 @@ import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.automirrored.filled.ArrowBack
10import androidx.compose.material.icons.automirrored.filled.ArrowBack
13import androidx.compose.material.icons.filled.Add
11import androidx.compose.material.icons.filled.Add
14import androidx.compose.material.icons.filled.ChatBubble
12import androidx.compose.material.icons.filled.ChatBubble
15−import androidx.compose.material.icons.filled.Settings
13+import androidx.compose.material.icons.automirrored.filled.Logout
16−import androidx.compose.material3.CircularProgressIndicator
17import androidx.compose.material3.ExperimentalMaterial3Api
14import androidx.compose.material3.ExperimentalMaterial3Api
18import androidx.compose.material3.Icon
15import androidx.compose.material3.Icon
19import androidx.compose.material3.IconButton
16import androidx.compose.material3.IconButton
20import androidx.compose.material3.ListItem
17import androidx.compose.material3.ListItem
21import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.MaterialTheme
22import androidx.compose.material3.Scaffold
19import androidx.compose.material3.Scaffold
23−import androidx.compose.material3.SnackbarHost
24import androidx.compose.material3.Text
20import androidx.compose.material3.Text
25import androidx.compose.material3.TopAppBar
21import androidx.compose.material3.TopAppBar
26import androidx.compose.material3.pulltorefresh.PullToRefreshBox
22import androidx.compose.material3.pulltorefresh.PullToRefreshBox
27import androidx.compose.runtime.Composable
23import androidx.compose.runtime.Composable
28import androidx.compose.runtime.collectAsState
24import androidx.compose.runtime.collectAsState
29import androidx.compose.runtime.getValue
25import androidx.compose.runtime.getValue
30−import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
26import androidx.compose.ui.Modifier
27+import androidx.compose.ui.res.stringResource
28+import androidx.compose.ui.semantics.Role
29+import androidx.compose.ui.semantics.contentDescription
30+import androidx.compose.ui.semantics.role
31+import androidx.compose.ui.semantics.semantics
32+import androidx.compose.ui.tooling.preview.Preview
32import androidx.compose.ui.unit.dp
33import androidx.compose.ui.unit.dp
34+import soy.iko.opencode.R
33import soy.iko.opencode.core.Core
35import soy.iko.opencode.core.Core
34import soy.iko.opencode.core.Event
36import soy.iko.opencode.core.Event
37+import soy.iko.opencode.core.SessionView
38+import soy.iko.opencode.ui.components.EmptyState
35import soy.iko.opencode.ui.components.ErrorHost
39import soy.iko.opencode.ui.components.ErrorHost
36import soy.iko.opencode.ui.components.ErrorSnackbarHost
40import soy.iko.opencode.ui.components.ErrorSnackbarHost
41+import soy.iko.opencode.ui.components.InfoHost
42+import soy.iko.opencode.ui.components.LoadingPlaceholder
43+import soy.iko.opencode.ui.theme.Dimens
44+import soy.iko.opencode.ui.theme.OpencodeTheme
37
45
38@OptIn(ExperimentalMaterial3Api::class)
46@OptIn(ExperimentalMaterial3Api::class)
39@Composable
47@Composable
40fun SessionsScreen(core: Core) {
48fun SessionsScreen(core: Core) {
41 val view by core.view.collectAsState()
49 val view by core.view.collectAsState()
42− val snackbarHostState = ErrorHost(core)
50+ val snackbarHostState = ErrorHost(
51+ core = core,
52+ dismissLabel = stringResource(R.string.action_dismiss),
53+ retryLabel = stringResource(R.string.action_retry),
54+ )
55+ InfoHost(
56+ core = core,
57+ successConnectedLabel = stringResource(R.string.connect_success),
58+ successSessionCreatedLabel = stringResource(R.string.session_created),
59+ snackbarHostState = snackbarHostState,
60+ )
43
61
44 Scaffold(
62 Scaffold(
45 topBar = {
63 topBar = {
46 TopAppBar(
64 TopAppBar(
47− title = { Text("Sessions") },
65+ title = { Text(stringResource(R.string.top_bar_sessions)) },
48 navigationIcon = {
66 navigationIcon = {
49 IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
67 IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
68+ Icon(
50− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Change server")
69+ Icons.AutoMirrored.Filled.ArrowBack,
70+ contentDescription = stringResource(R.string.sessions_cd_back),
71+ )
51 }
72 }
52 },
73 },
53 actions = {
74 actions = {
54 IconButton(onClick = { core.update(Event.CreateSession) }) {
75 IconButton(onClick = { core.update(Event.CreateSession) }) {
76+ Icon(
55− Icon(Icons.Default.Add, contentDescription = "New session")
77+ Icons.Default.Add,
78+ contentDescription = stringResource(R.string.sessions_cd_new),
79+ )
56 }
80 }
81+ // Disconnect: returns to the Connect screen to switch
82+ // servers / credentials. Labeled and iconed as logout so
83+ // users understand it ends the session, not "settings".
57 IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
84 IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
58− Icon(Icons.Default.Settings, contentDescription = "Settings")
85+ Icon(
86+ Icons.AutoMirrored.Filled.Logout,
87+ contentDescription = stringResource(R.string.sessions_cd_disconnect),
88+ )
59 }
89 }
60 },
90 },
61 )
91 )
@@ -67,48 +97,27 @@ fun SessionsScreen(core: Core) {
67 .fillMaxSize()
97 .fillMaxSize()
68 .padding(padding),
98 .padding(padding),
69 ) {
99 ) {
100+ when {
70− if (view.loading && view.sessions.isEmpty()) {
101+ view.loading && view.sessions.isEmpty() -> {
71− CircularProgressIndicator(
102+ LoadingPlaceholder()
72− modifier = Modifier.align(Alignment.Center),
103+ }
73− )
74− } else if (view.sessions.isEmpty()) {
104+ view.sessions.isEmpty() -> {
75− Column(
105+ EmptyState(message = stringResource(R.string.sessions_empty))
76− horizontalAlignment = Alignment.CenterHorizontally,
77− verticalArrangement = Arrangement.Center,
78− modifier = Modifier
79− .fillMaxSize()
80− .padding(24.dp),
81− ) {
82− Text(
83− "No sessions yet",
84− style = MaterialTheme.typography.bodyLarge,
85− color = MaterialTheme.colorScheme.onSurfaceVariant,
86− )
87 }
106 }
88− } else {
107+ else -> {
89− PullToRefreshBox(
108+ PullToRefreshBox(
90− isRefreshing = view.loading,
109+ isRefreshing = view.loading,
91− onRefresh = { core.update(Event.LoadSessions) },
110+ onRefresh = { core.update(Event.LoadSessions) },
92− modifier = Modifier.fillMaxSize(),
111+ modifier = Modifier.fillMaxSize(),
93− ) {
112+ ) {
94− LazyColumn {
113+ LazyColumn {
95− items(view.sessions, key = { it.id }) { session ->
114+ items(view.sessions, key = { it.id }) { session ->
96− ListItem(
115+ SessionRow(
97− headlineContent = {
116+ session = session,
98− Text(session.title.ifEmpty { "Untitled" })
117+ untitledLabel = stringResource(R.string.sessions_untitled),
99− },
100− supportingContent = { Text(session.id.take(8)) },
101− leadingContent = {
102− Icon(
103− Icons.Default.ChatBubble,
104− contentDescription = null,
105− tint = MaterialTheme.colorScheme.primary,
106− )
107− },
108− modifier = Modifier.clickable {
109− core.update(Event.SelectSession(session.id))
118+ onClick = { core.update(Event.SelectSession(session.id)) },
110− },
119+ )
111− )
120+ }
112 }
121 }
113 }
122 }
114 }
123 }
@@ -116,3 +125,85 @@ fun SessionsScreen(core: Core) {
116 }
125 }
117 }
126 }
118}
127}
128+
129+@Composable
130+private fun SessionRow(
131+ session: SessionView,
132+ untitledLabel: String,
133+ onClick: () -> Unit,
134+) {
135+ val title = session.title.ifEmpty { untitledLabel }
136+ ListItem(
137+ headlineContent = { Text(title) },
138+ supportingContent = { Text(session.id.take(8)) },
139+ leadingContent = {
140+ Icon(
141+ Icons.Default.ChatBubble,
142+ contentDescription = null,
143+ tint = MaterialTheme.colorScheme.primary,
144+ )
145+ },
146+ modifier = Modifier
147+ .clickable(onClick = onClick)
148+ .semantics(mergeDescendants = true) {
149+ // Merge the row into a single TalkBack utterance and announce
150+ // it as a button so users know it's actionable.
151+ role = Role.Button
152+ contentDescription = title
153+ },
154+ )
155+}
156+
157+@OptIn(ExperimentalMaterial3Api::class)
158+@Preview(showBackground = true)
159+@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
160+@Composable
161+private fun SessionsScreenPreview() {
162+ OpencodeTheme {
163+ Scaffold(topBar = {
164+ TopAppBar(
165+ title = { Text("Sessions") },
166+ navigationIcon = {
167+ IconButton(onClick = {}) {
168+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
169+ }
170+ },
171+ actions = {
172+ IconButton(onClick = {}) {
173+ Icon(Icons.Default.Add, contentDescription = "New session")
174+ }
175+ IconButton(onClick = {}) {
176+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Disconnect")
177+ }
178+ },
179+ )
180+ }) { padding ->
181+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
182+ LazyColumn {
183+ items(
184+ listOf(
185+ SessionView("abc12345def", "Refactor the parser"),
186+ SessionView("xyz12345abc", ""),
187+ ),
188+ key = { it.id },
189+ ) { session ->
190+ SessionRow(session, "Untitled", onClick = {})
191+ }
192+ }
193+ }
194+ }
195+ }
196+}
197+
198+@OptIn(ExperimentalMaterial3Api::class)
199+@Preview(showBackground = true)
200+@Composable
201+private fun SessionsScreenEmptyPreview() {
202+ OpencodeTheme {
203+ Scaffold(topBar = { TopAppBar(title = { Text("Sessions") }) }) { padding ->
204+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
205+ EmptyState(message = "No sessions yet")
206+ }
207+ }
208+ }
209+}
app/src/main/java/soy/iko/opencode/ui/theme/Dimens.ktnew file+45
@@ -0,0 +1,45 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.ui.unit.dp
4+
5+/**
6+ * Centralized spacing/dimension tokens. Replaces the ~14 inline magic `dp`
7+ * values that were scattered across the screen files. New UI should pull from
8+ * here so spacing stays consistent and drift becomes visible.
9+ */
10+object Dimens {
11+ // Screen-edge padding
12+ val screenPadding = 24.dp
13+
14+ // List content padding (horizontal gutters + vertical breathing room)
15+ val listHorizontalPadding = 12.dp
16+ val listVerticalPadding = 12.dp
17+ val listItemSpacing = 8.dp
18+
19+ // Generic spacers
20+ val spaceTiny = 4.dp
21+ val spaceSmall = 8.dp
22+ val spaceMedium = 12.dp
23+ val spaceLarge = 16.dp
24+ val spaceXLarge = 24.dp
25+
26+ // Bubble internals
27+ val bubbleHorizontalPadding = 14.dp
28+ val bubbleVerticalPadding = 10.dp
29+ val bubbleTailCorner = 4.dp
30+ val bubbleMaxWidthFraction = 0.82f
31+
32+ // Input bar
33+ val inputBarHorizontalPadding = 12.dp
34+ val inputBarVerticalPadding = 8.dp
35+ val inputBarTonalElevation = 3.dp
36+
37+ // Icons
38+ val iconHero = 72.dp
39+ val iconInputLeading = 16.dp
40+ val iconInlineSpinner = 14.dp
41+ val iconButtonSpinner = 20.dp
42+
43+ // Strokes
44+ val strokeThin = 2.dp
45+}
app/src/main/java/soy/iko/opencode/ui/theme/Shapes.ktnew file+33
@@ -0,0 +1,33 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.foundation.shape.RoundedCornerShape
4+import androidx.compose.material3.Shapes
5+import androidx.compose.ui.unit.dp
6+
7+/**
8+ * Centralized shape tokens. The chat-bubble shapes are defined here so the
9+ * user/assistant bubbles and the "generating" indicator all share one source
10+ * of truth instead of being hand-duplicated at each call site.
11+ */
12+val OpencodeShapes = Shapes(
13+ extraSmall = RoundedCornerShape(4.dp),
14+ small = RoundedCornerShape(8.dp),
15+ medium = RoundedCornerShape(12.dp),
16+ large = RoundedCornerShape(16.dp),
17+ extraLarge = RoundedCornerShape(28.dp),
18+)
19+
20+/** Tail-at-corner shapes for chat bubbles. */
21+val UserBubbleShape = RoundedCornerShape(
22+ topStart = 16.dp,
23+ topEnd = 16.dp,
24+ bottomStart = 16.dp,
25+ bottomEnd = 4.dp,
26+)
27+
28+val AssistantBubbleShape = RoundedCornerShape(
29+ topStart = 16.dp,
30+ topEnd = 16.dp,
31+ bottomStart = 4.dp,
32+ bottomEnd = 16.dp,
33+)
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+2
@@ -53,6 +53,8 @@ fun OpencodeTheme(
53 }
53 }
54 MaterialTheme(
54 MaterialTheme(
55 colorScheme = colorScheme,
55 colorScheme = colorScheme,
56+ typography = OpencodeTypography,
57+ shapes = OpencodeShapes,
56 content = content,
58 content = content,
57 )
59 )
58}
60}
app/src/main/java/soy/iko/opencode/ui/theme/Type.ktnew file+70
@@ -0,0 +1,70 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.material3.Typography
4+import androidx.compose.ui.text.TextStyle
5+import androidx.compose.ui.text.font.FontFamily
6+import androidx.compose.ui.text.font.FontWeight
7+import androidx.compose.ui.unit.sp
8+
9+/**
10+ * Typography scale. Stays close to the Material 3 baseline but pins the values
11+ * so that font sizes, weights, and letter spacing don't silently shift when
12+ * the Compose BOM updates. The screens reference these via
13+ * [androidx.compose.material3.MaterialTheme.typography].
14+ */
15+val OpencodeTypography = Typography(
16+ headlineSmall = TextStyle(
17+ fontFamily = FontFamily.Default,
18+ fontWeight = FontWeight.SemiBold,
19+ fontSize = 24.sp,
20+ lineHeight = 32.sp,
21+ letterSpacing = 0.sp,
22+ ),
23+ titleLarge = TextStyle(
24+ fontFamily = FontFamily.Default,
25+ fontWeight = FontWeight.SemiBold,
26+ fontSize = 22.sp,
27+ lineHeight = 28.sp,
28+ ),
29+ bodyLarge = TextStyle(
30+ fontFamily = FontFamily.Default,
31+ fontWeight = FontWeight.Normal,
32+ fontSize = 16.sp,
33+ lineHeight = 24.sp,
34+ letterSpacing = 0.5.sp,
35+ ),
36+ bodyMedium = TextStyle(
37+ fontFamily = FontFamily.Default,
38+ fontWeight = FontWeight.Normal,
39+ fontSize = 14.sp,
40+ lineHeight = 20.sp,
41+ letterSpacing = 0.25.sp,
42+ ),
43+ bodySmall = TextStyle(
44+ fontFamily = FontFamily.Default,
45+ fontWeight = FontWeight.Normal,
46+ fontSize = 12.sp,
47+ lineHeight = 16.sp,
48+ ),
49+ labelLarge = TextStyle(
50+ fontFamily = FontFamily.Default,
51+ fontWeight = FontWeight.Medium,
52+ fontSize = 14.sp,
53+ lineHeight = 20.sp,
54+ letterSpacing = 0.1.sp,
55+ ),
56+ labelMedium = TextStyle(
57+ fontFamily = FontFamily.Default,
58+ fontWeight = FontWeight.Medium,
59+ fontSize = 12.sp,
60+ lineHeight = 16.sp,
61+ letterSpacing = 0.5.sp,
62+ ),
63+ labelSmall = TextStyle(
64+ fontFamily = FontFamily.Default,
65+ fontWeight = FontWeight.Medium,
66+ fontSize = 11.sp,
67+ lineHeight = 16.sp,
68+ letterSpacing = 0.5.sp,
69+ ),
70+)
app/src/main/res/values/strings.xml+56
@@ -1,3 +1,59 @@
1<resources>
1<resources>
2 <string name="app_name">Opencode</string>
2 <string name="app_name">Opencode</string>
3+
4+ <!-- Top bar titles -->
5+ <string name="top_bar_connect">Opencode</string>
6+ <string name="top_bar_sessions">Sessions</string>
7+ <string name="top_bar_chat_fallback">Chat</string>
8+
9+ <!-- Connect screen -->
10+ <string name="connect_heading">Connect to Opencode Server</string>
11+ <string name="connect_field_server_url">Server URL</string>
12+ <string name="connect_field_username">Username</string>
13+ <string name="connect_field_password">Password</string>
14+ <string name="connect_auth_required">Server requires authentication</string>
15+ <string name="connect_password_hide">Hide password</string>
16+ <string name="connect_password_show">Show password</string>
17+ <string name="connect_button_cancel">Cancel</string>
18+ <string name="connect_button_connect">Connect</string>
19+ <string name="connect_success">Connected</string>
20+ <string name="connect_cd_dns">Connection icon</string>
21+ <string name="connect_cd_lock">Credentials icon</string>
22+ <string name="connect_cd_warning">Warning icon</string>
23+
24+ <!-- Crash log -->
25+ <string name="crash_reports_title">Crash Reports</string>
26+ <plurals name="crash_reports_view">
27+ <item quantity="one">%d crash report — view</item>
28+ <item quantity="other">%d crash report(s) — view</item>
29+ </plurals>
30+ <string name="crash_reports_clear">Clear all</string>
31+ <string name="crash_reports_close">Close</string>
32+
33+ <!-- Sessions screen -->
34+ <string name="sessions_empty">No sessions yet</string>
35+ <string name="sessions_cd_back">Change server</string>
36+ <string name="sessions_cd_new">New session</string>
37+ <string name="sessions_cd_disconnect">Disconnect</string>
38+ <string name="sessions_untitled">Untitled</string>
39+ <string name="session_created">Session created</string>
40+
41+ <!-- Chat screen -->
42+ <string name="chat_empty">Send a message to start chatting</string>
43+ <string name="chat_no_content">(no content)</string>
44+ <string name="chat_generating">Generating…</string>
45+ <string name="chat_input_placeholder">Message…</string>
46+ <string name="chat_cd_back">Back</string>
47+ <string name="chat_cd_send">Send</string>
48+ <string name="chat_role_user">User</string>
49+ <string name="chat_role_assistant">Assistant</string>
50+ <string name="chat_cd_generating">Generating reply</string>
51+
52+ <!-- Error / snackbar -->
53+ <string name="action_dismiss">Dismiss</string>
54+ <string name="action_retry">Retry</string>
55+
56+ <!-- SSE / connectivity -->
57+ <string name="sse_disconnected">Live stream disconnected</string>
58+ <string name="sse_reconnect">Reconnect</string>
3</resources>
59</resources>
gradle/libs.versions.toml−2
@@ -11,7 +11,6 @@ composeBom = "2026.01.01"
11lifecycle = "2.10.0"
11lifecycle = "2.10.0"
12ktor = "3.1.3"
12ktor = "3.1.3"
13kotlinxCoroutines = "1.10.2"
13kotlinxCoroutines = "1.10.2"
14−navigationCompose = "2.9.8"
15markdownRenderer = "0.43.0"
14markdownRenderer = "0.43.0"
16[libraries]
15[libraries]
17androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
16androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -30,7 +29,6 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te
30androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
29androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
31androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
30androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
32lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
31lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
33−androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
34
32
35markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3-android", version.ref = "markdownRenderer" }
33markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3-android", version.ref = "markdownRenderer" }
36markdown-renderer-core = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }
34markdown-renderer-core = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }