@@ -5,29 +5,18 @@ import androidx.compose.foundation.layout.Arrangement
55import androidx.compose.foundation.layout.Box
66import androidx.compose.foundation.layout.Column
77import androidx.compose.foundation.layout.PaddingValues
8−import androidx.compose.foundation.layout.Row
9−import androidx.compose.foundation.layout.Spacer
108import androidx.compose.foundation.layout.fillMaxSize
11−import androidx.compose.foundation.layout.fillMaxWidth
12−import androidx.compose.foundation.layout.height
139import androidx.compose.foundation.layout.padding
14−import androidx.compose.foundation.layout.size
15−import androidx.compose.foundation.layout.widthIn
1610import androidx.compose.foundation.lazy.LazyColumn
1711import androidx.compose.foundation.lazy.items
1812import androidx.compose.foundation.lazy.rememberLazyListState
19−import androidx.compose.foundation.shape.RoundedCornerShape
2013import androidx.compose.material.icons.Icons
2114import androidx.compose.material.icons.automirrored.filled.ArrowBack
22−import androidx.compose.material.icons.automirrored.filled.Send
23−import androidx.compose.material3.CircularProgressIndicator
2415import androidx.compose.material3.ExperimentalMaterial3Api
2516import androidx.compose.material3.Icon
2617import androidx.compose.material3.IconButton
2718import androidx.compose.material3.MaterialTheme
28−import androidx.compose.material3.OutlinedTextField
2919import androidx.compose.material3.Scaffold
30−import androidx.compose.material3.Surface
3120import androidx.compose.material3.Text
3221import androidx.compose.material3.TopAppBar
3322import androidx.compose.runtime.Composable
@@ -38,26 +27,33 @@ import androidx.compose.runtime.getValue
3827import androidx.compose.runtime.mutableStateOf
3928import androidx.compose.runtime.remember
4029import androidx.compose.runtime.setValue
41−import androidx.compose.ui.Alignment
4230import androidx.compose.ui.Modifier
43−import androidx.compose.ui.platform.LocalConfiguration
44−import androidx.compose.ui.text.font.FontStyle
31+import androidx.compose.ui.res.stringResource
4532import androidx.compose.ui.text.style.TextOverflow
33+import androidx.compose.ui.tooling.preview.Preview
4634import androidx.compose.ui.unit.dp
47−import com.mikepenz.markdown.m3.Markdown
35+import soy.iko.opencode.R
4836import soy.iko.opencode.core.Core
4937import soy.iko.opencode.core.Event
5038import 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
5142import soy.iko.opencode.ui.components.ErrorHost
5243import soy.iko.opencode.ui.components.ErrorSnackbarHost
53−import java.text.SimpleDateFormat
54−import java.util.Date
55−import java.util.Locale
44+import soy.iko.opencode.ui.components.GeneratingIndicator
45+import soy.iko.opencode.ui.components.InfoHost
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
5651
5752@OptIn(ExperimentalMaterial3Api::class)
5853@Composable
5954fun ChatScreen(core: Core) {
6055 val view by core.view.collectAsState()
56+ val sseState by core.sseState.collectAsState()
6157 var inputText by remember { mutableStateOf("") }
6258 val listState = rememberLazyListState()
6359
@@ -77,6 +73,21 @@ fun ChatScreen(core: Core) {
7773 }
7874 }
7975
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+
8091 Scaffold(
8192 topBar = {
8293 TopAppBar(
@@ -84,7 +95,8 @@ fun ChatScreen(core: Core) {
8495 Column {
8596 Text(
8697 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)
88100 },
89101 maxLines = 1,
90102 overflow = TextOverflow.Ellipsis,
@@ -101,7 +113,10 @@ fun ChatScreen(core: Core) {
101113 },
102114 navigationIcon = {
103115 IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
104− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
116+ Icon(
117+ Icons.AutoMirrored.Filled.ArrowBack,
118+ contentDescription = stringResource(R.string.chat_cd_back),
119+ )
105120 }
106121 },
107122 )
@@ -117,46 +132,54 @@ fun ChatScreen(core: Core) {
117132 }
118133 },
119134 enabled = !view.loading,
135+ placeholder = stringResource(R.string.chat_input_placeholder),
136+ sendContentDescription = stringResource(R.string.chat_cd_send),
120137 )
121138 },
122− snackbarHost = { ErrorSnackbarHost(ErrorHost(core)) },
139+ snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
123140 ) { padding ->
124141 Box(
125142 modifier = Modifier
126143 .fillMaxSize()
127144 .padding(padding),
128145 ) {
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),
138− ) {
139− Text(
140− "Send a message to start chatting",
141− style = MaterialTheme.typography.bodyLarge,
142− color = MaterialTheme.colorScheme.onSurfaceVariant,
146+ Column(modifier = Modifier.fillMaxSize()) {
147+ if (showSseBanner) {
148+ val bannerMessage = (sseState as? SseState.Error)?.message
149+ ?: stringResource(R.string.sse_disconnected)
150+ SseDisconnectedBanner(
151+ message = bannerMessage,
152+ onReconnect = { core.reconnectSse() },
153+ reconnectLabel = stringResource(R.string.sse_reconnect),
143154 )
144155 }
145− } else {
146− LazyColumn(
147− state = listState,
148− modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
149− verticalArrangement = Arrangement.spacedBy(8.dp),
150− contentPadding = PaddingValues(vertical = 12.dp),
151− ) {
152− items(view.messages, key = { it.id }) { message ->
153− MessageBubble(message)
156+
157+ when {
158+ view.loading && view.messages.isEmpty() -> {
159+ LoadingPlaceholder()
154160 }
155− // "Thinking" indicator shown after the user sends, while
156− // the assistant reply hasn't arrived yet.
157− item(key = "__generating__") {
158− AnimatedVisibility(visible = view.generating) {
159− GeneratingIndicator()
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+ }
176+ // "Thinking" indicator shown after the user sends, while
177+ // the assistant reply hasn't arrived yet.
178+ item(key = "__generating__") {
179+ AnimatedVisibility(visible = view.generating) {
180+ GeneratingIndicator()
181+ }
182+ }
160183 }
161184 }
162185 }
@@ -165,159 +188,99 @@ fun ChatScreen(core: Core) {
165188 }
166189}
167190
191+@OptIn(ExperimentalMaterial3Api::class)
192+@Preview(showBackground = true)
193+@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
168194@Composable
169−private fun MessageBubble(message: MessageView) {
170− val isUser = message.role == "user"
171− val maxWidth = LocalConfiguration.current.screenWidthDp.dp * 0.82f
172− Row(
173− modifier = Modifier.fillMaxWidth(),
174− horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
175− ) {
176− Surface(
177− color = if (isUser) {
178− MaterialTheme.colorScheme.primary
179− } else {
180− MaterialTheme.colorScheme.surfaceVariant
181− },
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,
195+private fun ChatScreenPreview() {
196+ OpencodeTheme {
197+ PreviewChatScreen(
198+ title = "Refactor the parser",
199+ sessionId = "abc12345",
200+ messages = listOf(
201+ MessageView("u1", "user", "Can you clean up parse_messages?", 1_700_000_000uL),
202+ MessageView(
203+ "a1",
204+ "assistant",
205+ "Sure — here's a **plan**:\n\n1. Extract helpers\n2. Add tests",
206+ 1_700_000_010uL,
207+ ),
192208 ),
193− modifier = Modifier.widthIn(max = maxWidth),
194− ) {
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− }
209+ generating = false,
210+ )
240211 }
241212}
242213
214+@OptIn(ExperimentalMaterial3Api::class)
215+@Preview(showBackground = true)
243216@Composable
244−private fun GeneratingIndicator() {
245− Row(
246− horizontalArrangement = Arrangement.Start,
247− modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
248− ) {
249− Surface(
250− color = MaterialTheme.colorScheme.surfaceVariant,
251− contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
252− shape = RoundedCornerShape(
253− topStart = 16.dp,
254− topEnd = 16.dp,
255− bottomStart = 4.dp,
256− bottomEnd = 16.dp,
257− ),
258− modifier = Modifier.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.82f),
259− ) {
260− Row(
261− verticalAlignment = Alignment.CenterVertically,
262− modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
263− ) {
264− CircularProgressIndicator(
265− modifier = Modifier.size(14.dp),
266− strokeWidth = 2.dp,
267− )
268− Spacer(Modifier.size(8.dp))
269− Text(
270− text = "Generating…",
271− style = MaterialTheme.typography.bodyMedium,
272− fontStyle = FontStyle.Italic,
273− )
274− }
275− }
217+private fun ChatScreenGeneratingPreview() {
218+ OpencodeTheme {
219+ PreviewChatScreen(
220+ title = "New session",
221+ sessionId = "xyz12345",
222+ messages = listOf(MessageView("u1", "user", "Hello!", 1_700_000_000uL)),
223+ generating = true,
224+ )
225+ }
226+}
227+
228+@OptIn(ExperimentalMaterial3Api::class)
229+@Preview(showBackground = true)
230+@Composable
231+private fun ChatScreenEmptyPreview() {
232+ OpencodeTheme {
233+ PreviewChatScreen(title = "", sessionId = null, messages = emptyList(), generating = false)
276234 }
277235}
278236
237+@OptIn(ExperimentalMaterial3Api::class)
279238@Composable
280−private fun ChatInputBar(
281− text: String,
282− onTextChange: (String) -> Unit,
283− onSend: () -> Unit,
284− enabled: Boolean,
239+private fun PreviewChatScreen(
240+ title: String,
241+ sessionId: String?,
242+ messages: List<MessageView>,
243+ generating: Boolean,
285244) {
286− Surface(
287− tonalElevation = 3.dp,
288− modifier = Modifier.fillMaxWidth(),
289− ) {
290− Row(
291− verticalAlignment = Alignment.CenterVertically,
292− modifier = Modifier
293− .fillMaxWidth()
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,
245+ Scaffold(
246+ topBar = {
247+ TopAppBar(
248+ title = {
249+ Text(
250+ text = title.ifEmpty {
251+ sessionId?.take(8)?.plus("…") ?: "Chat"
252+ },
253+ maxLines = 1,
254+ overflow = TextOverflow.Ellipsis,
255+ )
256+ },
257+ navigationIcon = {
258+ IconButton(onClick = {}) {
259+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
260+ }
261+ },
302262 )
303− Spacer(Modifier.size(8.dp))
304− // Rely on the standard disabled styling instead of overriding
305− // the tint manually.
306− IconButton(
307− onClick = onSend,
308− enabled = enabled && text.isNotBlank(),
309− ) {
310− Icon(
311− Icons.AutoMirrored.Filled.Send,
312− contentDescription = "Send",
313− )
263+ },
264+ bottomBar = {
265+ ChatInputBar(text = "", onTextChange = {}, onSend = {}, enabled = true)
266+ },
267+ ) { padding ->
268+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
269+ when {
270+ messages.isEmpty() -> EmptyState(message = "Send a message to start chatting")
271+ else -> {
272+ LazyColumn(
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+ }
314283 }
315284 }
316285 }
317286}
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−}