@@ -1,6 +1,7 @@
11package soy.iko.crush.ui.screens
22
33import androidx.compose.foundation.background
4+import androidx.compose.foundation.clickable
45import androidx.compose.foundation.layout.Arrangement
56import androidx.compose.foundation.layout.Box
67import androidx.compose.foundation.layout.Column
@@ -15,28 +16,33 @@ import androidx.compose.foundation.layout.widthIn
1516import androidx.compose.foundation.lazy.LazyColumn
1617import androidx.compose.foundation.lazy.items
1718import androidx.compose.foundation.lazy.rememberLazyListState
19+import androidx.compose.foundation.shape.CircleShape
1820import androidx.compose.foundation.shape.RoundedCornerShape
21+import androidx.compose.foundation.text.KeyboardActions
22+import androidx.compose.foundation.text.KeyboardOptions
1923import androidx.compose.material.icons.Icons
2024import androidx.compose.material.icons.automirrored.filled.Send
2125import androidx.compose.material.icons.filled.Add
26+import androidx.compose.material.icons.filled.MoreVert
2227import androidx.compose.material.icons.filled.Stop
2328import androidx.compose.material3.AlertDialog
2429import androidx.compose.material3.Button
25−import androidx.compose.material3.Card
26−import androidx.compose.material3.CardDefaults
2730import androidx.compose.material3.CircularProgressIndicator
31+import androidx.compose.material3.DropdownMenu
32+import androidx.compose.material3.DropdownMenuItem
2833import androidx.compose.material3.ExperimentalMaterial3Api
29−import androidx.compose.material3.FloatingActionButton
3034import androidx.compose.material3.HorizontalDivider
3135import androidx.compose.material3.Icon
3236import androidx.compose.material3.IconButton
3337import androidx.compose.material3.MaterialTheme
38+import androidx.compose.material3.ModalBottomSheet
3439import androidx.compose.material3.OutlinedTextField
3540import androidx.compose.material3.Scaffold
3641import androidx.compose.material3.Surface
3742import androidx.compose.material3.Text
3843import androidx.compose.material3.TextButton
3944import androidx.compose.material3.TopAppBar
45+import androidx.compose.material3.rememberModalBottomSheetState
4046import androidx.compose.runtime.Composable
4147import androidx.compose.runtime.LaunchedEffect
4248import androidx.compose.runtime.getValue
@@ -46,12 +52,19 @@ import androidx.compose.runtime.setValue
4652import androidx.compose.ui.Alignment
4753import androidx.compose.ui.Modifier
4854import androidx.compose.ui.draw.clip
55+import androidx.compose.ui.platform.LocalContext
4956import androidx.compose.ui.text.font.FontFamily
5057import androidx.compose.ui.text.font.FontWeight
58+import androidx.compose.ui.text.input.ImeAction
5159import androidx.compose.ui.unit.dp
60+import soy.iko.crush.proto.Message
5261import soy.iko.crush.proto.MessageRole
62+import soy.iko.crush.proto.PermissionRequest
63+import soy.iko.crush.proto.Session
5364import soy.iko.crush.ui.ChatUiState
5465import soy.iko.crush.ui.MessageRenderer
66+import java.text.DateFormat
67+import java.util.Date
5568
5669@OptIn(ExperimentalMaterial3Api::class)
5770@Composable
@@ -61,13 +74,14 @@ fun ChatScreen(
6174 onCancel: () -> Unit,
6275 onNewSession: () -> Unit,
6376 onPickSession: (String) -> Unit,
64− onGrantPermission: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
77+ onGrantPermission: (PermissionRequest, Boolean) -> Unit,
78+ onDisconnect: () -> Unit,
6579) {
6680 var input by remember { mutableStateOf("") }
6781 var showSessionPicker by remember { mutableStateOf(false) }
82+ var showOverflow by remember { mutableStateOf(false) }
6883 val listState = rememberLazyListState()
6984
70− // Auto-scroll to bottom when new messages arrive.
7185 LaunchedEffect(state.messages.size) {
7286 if (state.messages.isNotEmpty()) {
7387 listState.animateScrollToItem(state.messages.lastIndex)
@@ -79,8 +93,12 @@ fun ChatScreen(
7993 TopAppBar(
8094 title = {
8195 Column {
82− Text("Crush", fontWeight = FontWeight.Bold)
83− val subtitle = state.currentSessionId?.let { "session $it" } ?: "no session"
96+ Row(verticalAlignment = Alignment.CenterVertically) {
97+ Text("Crush", fontWeight = FontWeight.Bold)
98+ Spacer(Modifier.size(6.dp))
99+ StatusDot(state)
100+ }
101+ val subtitle = state.currentSessionId?.let { "session ${it.take(8)}" } ?: "no session"
84102 Text(
85103 subtitle,
86104 style = MaterialTheme.typography.labelSmall,
@@ -101,6 +119,30 @@ fun ChatScreen(
101119 IconButton(onClick = { showSessionPicker = true }) {
102120 Icon(Icons.Filled.Add, contentDescription = "Sessions")
103121 }
122+ Box {
123+ IconButton(onClick = { showOverflow = true }) {
124+ Icon(Icons.Filled.MoreVert, contentDescription = "More")
125+ }
126+ DropdownMenu(
127+ expanded = showOverflow,
128+ onDismissRequest = { showOverflow = false },
129+ ) {
130+ DropdownMenuItem(
131+ text = { Text("Switch session") },
132+ onClick = {
133+ showOverflow = false
134+ showSessionPicker = true
135+ },
136+ )
137+ DropdownMenuItem(
138+ text = { Text("Disconnect") },
139+ onClick = {
140+ showOverflow = false
141+ onDisconnect()
142+ },
143+ )
144+ }
145+ }
104146 },
105147 )
106148 },
@@ -126,20 +168,28 @@ fun ChatScreen(
126168 if (state.error != null) {
127169 ErrorBanner(state.error)
128170 }
129− LazyColumn(
130− state = listState,
131− modifier = Modifier
132− .weight(1f)
133− .fillMaxWidth()
134− .padding(horizontal = 12.dp),
135− verticalArrangement = Arrangement.spacedBy(8.dp),
136− contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
137− ) {
138− items(state.messages, key = { it.id }) { msg ->
139− MessageBubble(msg)
140− }
141− if (state.isBusy && state.messages.isEmpty()) {
142− item { TypingIndicator() }
171+ if (state.messages.isEmpty() && !state.isBusy) {
172+ EmptyConversation(
173+ modifier = Modifier
174+ .weight(1f)
175+ .fillMaxWidth(),
176+ )
177+ } else {
178+ LazyColumn(
179+ state = listState,
180+ modifier = Modifier
181+ .weight(1f)
182+ .fillMaxWidth()
183+ .padding(horizontal = 12.dp),
184+ verticalArrangement = Arrangement.spacedBy(8.dp),
185+ contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
186+ ) {
187+ items(state.messages, key = { it.id }) { msg ->
188+ MessageBubble(msg)
189+ }
190+ if (state.isBusy && state.messages.isEmpty()) {
191+ item { TypingIndicator() }
192+ }
143193 }
144194 }
145195 PermissionQueue(
@@ -167,27 +217,74 @@ fun ChatScreen(
167217}
168218
169219@Composable
170−private fun MessageBubble(message: soy.iko.crush.proto.Message) {
220+private fun StatusDot(state: ChatUiState) {
221+ val color = when {
222+ state.isBusy -> MaterialTheme.colorScheme.tertiary
223+ state.currentSessionId != null -> MaterialTheme.colorScheme.primary
224+ else -> MaterialTheme.colorScheme.outline
225+ }
226+ Box(
227+ modifier = Modifier
228+ .size(8.dp)
229+ .clip(CircleShape)
230+ .background(color),
231+ )
232+}
233+
234+@Composable
235+private fun MessageBubble(message: Message) {
171236 val isUser = message.role == MessageRole.user
172237 val bg = if (isUser) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
173238 else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
174239 val alignment = if (isUser) Alignment.End else Alignment.Start
240+ val context = LocalContext.current
175241
176242 Column(
177243 modifier = Modifier.fillMaxWidth(),
178244 horizontalAlignment = alignment,
179245 ) {
180− Text(
181− MessageRenderer.roleLabel(message.role),
182− style = MaterialTheme.typography.labelSmall,
183− color = MaterialTheme.colorScheme.onSurfaceVariant,
246+ Row(
247+ verticalAlignment = Alignment.CenterVertically,
248+ horizontalArrangement = Arrangement.spacedBy(6.dp),
184249 modifier = Modifier.padding(bottom = 2.dp, start = 4.dp, end = 4.dp),
185− )
250+ ) {
251+ Text(
252+ MessageRenderer.roleLabel(message.role),
253+ style = MaterialTheme.typography.labelSmall,
254+ color = MaterialTheme.colorScheme.onSurfaceVariant,
255+ )
256+ if (message.createdAt > 0) {
257+ Text(
258+ "·",
259+ style = MaterialTheme.typography.labelSmall,
260+ color = MaterialTheme.colorScheme.onSurfaceVariant,
261+ )
262+ Text(
263+ formatTimestamp(message.createdAt),
264+ style = MaterialTheme.typography.labelSmall,
265+ color = MaterialTheme.colorScheme.onSurfaceVariant,
266+ )
267+ }
268+ }
186269 Box(
187270 modifier = Modifier
188271 .widthIn(max = 340.dp)
189272 .clip(RoundedCornerShape(16.dp))
190273 .background(bg)
274+ .clickable(
275+ enabled = message.parts.any { it.type == soy.iko.crush.proto.PartType.Text },
276+ onClick = {
277+ val text = MessageRenderer.renderText(message)
278+ if (text.isNotEmpty()) {
279+ val clipboard =
280+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE)
281+ as android.content.ClipboardManager
282+ clipboard.setPrimaryClip(
283+ android.content.ClipData.newPlainText("Crush", text),
284+ )
285+ }
286+ },
287+ )
191288 .padding(12.dp),
192289 ) {
193290 val text = MessageRenderer.renderText(message)
@@ -200,7 +297,6 @@ private fun MessageBubble(message: soy.iko.crush.proto.Message) {
200297 color = MaterialTheme.colorScheme.onSurface,
201298 )
202299 } else if (message.parts.isNotEmpty()) {
203− // Non-text parts (tool calls etc.) — render compactly.
204300 Text(
205301 message.parts.joinToString("\n") { MessageRenderer.renderPart(it) },
206302 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
@@ -232,6 +328,14 @@ private fun InputBar(
232328 placeholder = { Text("Message Crush…") },
233329 maxLines = 5,
234330 enabled = enabled,
331+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
332+ keyboardActions = KeyboardActions(
333+ onSend = {
334+ if (enabled && text.isNotBlank()) {
335+ onSend()
336+ }
337+ },
338+ ),
235339 )
236340 Spacer(Modifier.size(8.dp))
237341 IconButton(onClick = onSend, enabled = enabled && text.isNotBlank()) {
@@ -243,16 +347,14 @@ private fun InputBar(
243347
244348@Composable
245349private fun ErrorBanner(message: String) {
246− Box(
247− modifier = Modifier
248− .fillMaxWidth()
249− .background(MaterialTheme.colorScheme.error.copy(alpha = 0.12f))
250− .padding(8.dp),
251− ) {
350+ Surface(color = MaterialTheme.colorScheme.errorContainer, tonalElevation = 2.dp) {
252351 Text(
253352 message,
254− color = MaterialTheme.colorScheme.error,
353+ color = MaterialTheme.colorScheme.onErrorContainer,
255354 style = MaterialTheme.typography.bodySmall,
355+ modifier = Modifier
356+ .fillMaxWidth()
357+ .padding(12.dp),
256358 )
257359 }
258360}
@@ -269,10 +371,36 @@ private fun TypingIndicator() {
269371 }
270372}
271373
374+@Composable
375+private fun EmptyConversation(modifier: Modifier = Modifier) {
376+ Column(
377+ modifier = modifier,
378+ horizontalAlignment = Alignment.CenterHorizontally,
379+ verticalArrangement = Arrangement.Center,
380+ ) {
381+ Text(
382+ "💬",
383+ style = MaterialTheme.typography.displayMedium,
384+ )
385+ Spacer(Modifier.height(8.dp))
386+ Text(
387+ "Say hello to Crush",
388+ style = MaterialTheme.typography.titleMedium,
389+ color = MaterialTheme.colorScheme.onSurface,
390+ )
391+ Spacer(Modifier.height(4.dp))
392+ Text(
393+ "Send a message below to start the conversation.",
394+ style = MaterialTheme.typography.bodySmall,
395+ color = MaterialTheme.colorScheme.onSurfaceVariant,
396+ )
397+ }
398+}
399+
272400@Composable
273401private fun PermissionQueue(
274− requests: List<soy.iko.crush.proto.PermissionRequest>,
275− onGrant: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
402+ requests: List<PermissionRequest>,
403+ onGrant: (PermissionRequest, Boolean) -> Unit,
276404) {
277405 if (requests.isEmpty()) return
278406 val req = requests.first()
@@ -291,41 +419,96 @@ private fun PermissionQueue(
291419 )
292420}
293421
422+@OptIn(ExperimentalMaterial3Api::class)
294423@Composable
295424private fun SessionPickerSheet(
296− sessions: List<soy.iko.crush.proto.Session>,
425+ sessions: List<Session>,
297426 currentId: String?,
298427 onPick: (String?) -> Unit,
299428 onNew: () -> Unit,
300429 onDismiss: () -> Unit,
301430) {
302− AlertDialog(
431+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
432+ ModalBottomSheet(
303433 onDismissRequest = onDismiss,
304− title = { Text("Sessions") },
305− text = {
306− Column {
434+ sheetState = sheetState,
435+ ) {
436+ Column(
437+ modifier = Modifier
438+ .fillMaxWidth()
439+ .padding(horizontal = 16.dp, vertical = 8.dp),
440+ ) {
441+ Row(
442+ modifier = Modifier.fillMaxWidth(),
443+ horizontalArrangement = Arrangement.SpaceBetween,
444+ verticalAlignment = Alignment.CenterVertically,
445+ ) {
446+ Text(
447+ "Sessions",
448+ style = MaterialTheme.typography.titleLarge,
449+ fontWeight = FontWeight.Bold,
450+ )
451+ TextButton(onClick = onNew) { Text("New session") }
452+ }
453+ Spacer(Modifier.height(8.dp))
454+ if (sessions.isEmpty()) {
455+ Text(
456+ "No sessions yet",
457+ color = MaterialTheme.colorScheme.onSurfaceVariant,
458+ modifier = Modifier.padding(vertical = 16.dp),
459+ )
460+ } else {
307461 sessions.forEach { s ->
308− val active = s.id == currentId
309− Text(
310− s.title.ifBlank { s.id.take(8) },
311− fontWeight = if (active) FontWeight.Bold else FontWeight.Normal,
312− color = if (active) MaterialTheme.colorScheme.primary
313− else MaterialTheme.colorScheme.onSurface,
314− modifier = Modifier
315− .fillMaxWidth()
316− .padding(8.dp)
317− .clip(RoundedCornerShape(8.dp))
318− .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
319− .padding(8.dp),
320− )
321− Spacer(Modifier.height(4.dp))
322− }
323− if (sessions.isEmpty()) {
324− Text("No sessions yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
462+ SessionRow(s, isActive = s.id == currentId, onClick = { onPick(s.id) })
463+ HorizontalDivider()
325464 }
326465 }
327− },
328− confirmButton = { Button(onNew) { Text("New session") } },
329− dismissButton = { TextButton(onDismiss) { Text("Close") } },
330− )
466+ Spacer(Modifier.height(16.dp))
467+ }
468+ }
469+}
470+
471+@Composable
472+private fun SessionRow(session: Session, isActive: Boolean, onClick: () -> Unit) {
473+ val title = session.title.ifBlank { session.id.take(8) }
474+ Column(
475+ modifier = Modifier
476+ .fillMaxWidth()
477+ .clickable(onClick = onClick)
478+ .padding(vertical = 12.dp, horizontal = 8.dp),
479+ ) {
480+ Row(
481+ modifier = Modifier.fillMaxWidth(),
482+ verticalAlignment = Alignment.CenterVertically,
483+ ) {
484+ Text(
485+ title,
486+ fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
487+ color = if (isActive) MaterialTheme.colorScheme.primary
488+ else MaterialTheme.colorScheme.onSurface,
489+ style = MaterialTheme.typography.bodyLarge,
490+ modifier = Modifier.weight(1f),
491+ )
492+ if (isActive) {
493+ Box(
494+ modifier = Modifier
495+ .size(8.dp)
496+ .clip(CircleShape)
497+ .background(MaterialTheme.colorScheme.primary),
498+ )
499+ }
500+ }
501+ if (session.messageCount > 0) {
502+ Text(
503+ "${session.messageCount} messages",
504+ style = MaterialTheme.typography.labelSmall,
505+ color = MaterialTheme.colorScheme.onSurfaceVariant,
506+ )
507+ }
508+ }
509+}
510+
511+private fun formatTimestamp(epochSeconds: Long): String {
512+ val timeMs = epochSeconds * 1000L
513+ return DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(timeMs))
331514}