@@ -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
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()
@@ -77,6 +73,21 @@ fun ChatScreen(core: Core) {
76+ val snackbarHostState = ErrorHost(
78+ dismissLabel = stringResource(R.string.action_dismiss),
79+ retryLabel = stringResource(R.string.action_retry),
83+ successConnectedLabel = stringResource(R.string.connect_success),
84+ successSessionCreatedLabel = stringResource(R.string.session_created),
85+ snackbarHostState = snackbarHostState,
88+ val showSseBanner = sseState is SseState.Error ||
89+ (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
81 topBar = {
92 topBar = {
82 TopAppBar(
93 TopAppBar(
@@ -84,7 +95,8 @@ fun ChatScreen(core: Core) {
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)
89 maxLines = 1,
101 maxLines = 1,
90 overflow = TextOverflow.Ellipsis,
102 overflow = TextOverflow.Ellipsis,
@@ -101,7 +113,10 @@ fun ChatScreen(core: Core) {
102 navigationIcon = {
114 navigationIcon = {
103 IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
115 IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
104− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
117+ Icons.AutoMirrored.Filled.ArrowBack,
118+ contentDescription = stringResource(R.string.chat_cd_back),
@@ -117,46 +132,54 @@ fun ChatScreen(core: Core) {
119 enabled = !view.loading,
134 enabled = !view.loading,
135+ placeholder = stringResource(R.string.chat_input_placeholder),
136+ sendContentDescription = stringResource(R.string.chat_cd_send),
122− snackbarHost = { ErrorSnackbarHost(ErrorHost(core)) },
139+ snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
123 ) { padding ->
140 ) { padding ->
125 modifier = Modifier
142 modifier = Modifier
126 .fillMaxSize()
143 .fillMaxSize()
127 .padding(padding),
144 .padding(padding),
129− if (view.loading && view.messages.isEmpty()) {
130− CircularProgressIndicator(
131− modifier = Modifier.align(Alignment.Center),
133− } else if (view.messages.isEmpty()) {
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),
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),
152− items(view.messages, key = { it.id }) { message ->
153− MessageBubble(message)
161+ view.messages.isEmpty() -> {
162+ EmptyState(message = stringResource(R.string.chat_empty))
169+ .padding(horizontal = Dimens.listHorizontalPadding),
170+ verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
171+ contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
173+ items(view.messages, key = { it.id }) { message ->
174+ MessageBubble(message)
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()
@@ -165,159 +188,99 @@ fun ChatScreen(core: Core) {
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,
182− contentColor = if (isUser) {
183− MaterialTheme.colorScheme.onPrimary
185− MaterialTheme.colorScheme.onSurfaceVariant
187− shape = RoundedCornerShape(
190− bottomStart = if (isUser) 16.dp else 4.dp,
191− bottomEnd = if (isUser) 4.dp else 16.dp,
193− modifier = Modifier.widthIn(max = maxWidth),
209+ generating = false,
196− modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
200− modifier = Modifier.fillMaxWidth(),
201− horizontalArrangement = Arrangement.SpaceBetween,
202− verticalAlignment = Alignment.CenterVertically,
205− text = message.role.replaceFirstChar { it.uppercase() },
206− style = MaterialTheme.typography.labelSmall,
207− color = MaterialTheme.colorScheme.primary,
209− if (message.time > 0uL) {
211− text = formatTimestamp(message.time),
212− style = MaterialTheme.typography.labelSmall,
213− color = MaterialTheme.colorScheme.onSurfaceVariant,
217− Spacer(Modifier.height(2.dp))
219− if (message.text.isBlank()) {
221− text = "(no content)",
222− style = MaterialTheme.typography.bodyMedium,
223− fontStyle = FontStyle.Italic,
225− } else if (isUser) {
227− text = message.text,
228− style = MaterialTheme.typography.bodyMedium,
231− // Assistant replies often contain markdown (code blocks,
232− // lists, etc.) — render them properly.
234− content = message.text,
235− modifier = Modifier.fillMaxWidth(),
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(
258− modifier = Modifier.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.82f),
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)
270− text = "Generating…",
271− style = MaterialTheme.typography.bodyMedium,
272− fontStyle = FontStyle.Italic,
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,
286− Surface(
245+ Scaffold(
287− tonalElevation = 3.dp,
246+ topBar = {
288− modifier = Modifier.fillMaxWidth(),
247+ TopAppBar(
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),
298− onValueChange = onTextChange,
299− placeholder = { Text("Message…") },
300− modifier = Modifier.weight(1f),
301− maxLines = 4,
253+ maxLines = 1,
254+ overflow = TextOverflow.Ellipsis,
258+ IconButton(onClick = {}) {
259+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
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)
307− onClick = onSend,
267+ ) { padding ->
308− enabled = enabled && text.isNotBlank(),
268+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
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),
277+ items(messages, key = { it.id }) { MessageBubble(it) }
279+ item(key = "__generating__") { GeneratingIndicator() }
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)