@@ -1,6 +1,7 @@
1package soy.iko.crush.ui.screens
1package soy.iko.crush.ui.screens
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4+import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
@@ -15,28 +16,33 @@ import androidx.compose.foundation.layout.widthIn
15import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.rememberLazyListState
18import androidx.compose.foundation.lazy.rememberLazyListState
19+import androidx.compose.foundation.shape.CircleShape
18import androidx.compose.foundation.shape.RoundedCornerShape
20import androidx.compose.foundation.shape.RoundedCornerShape
21+import androidx.compose.foundation.text.KeyboardActions
22+import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.automirrored.filled.Send
24import androidx.compose.material.icons.automirrored.filled.Send
21import androidx.compose.material.icons.filled.Add
25import androidx.compose.material.icons.filled.Add
26+import androidx.compose.material.icons.filled.MoreVert
22import androidx.compose.material.icons.filled.Stop
27import androidx.compose.material.icons.filled.Stop
23import androidx.compose.material3.AlertDialog
28import androidx.compose.material3.AlertDialog
24import androidx.compose.material3.Button
29import androidx.compose.material3.Button
25−import androidx.compose.material3.Card
26−import androidx.compose.material3.CardDefaults
27import androidx.compose.material3.CircularProgressIndicator
30import androidx.compose.material3.CircularProgressIndicator
31+import androidx.compose.material3.DropdownMenu
32+import androidx.compose.material3.DropdownMenuItem
28import androidx.compose.material3.ExperimentalMaterial3Api
33import androidx.compose.material3.ExperimentalMaterial3Api
29−import androidx.compose.material3.FloatingActionButton
30import androidx.compose.material3.HorizontalDivider
34import androidx.compose.material3.HorizontalDivider
31import androidx.compose.material3.Icon
35import androidx.compose.material3.Icon
32import androidx.compose.material3.IconButton
36import androidx.compose.material3.IconButton
33import androidx.compose.material3.MaterialTheme
37import androidx.compose.material3.MaterialTheme
38+import androidx.compose.material3.ModalBottomSheet
34import androidx.compose.material3.OutlinedTextField
39import androidx.compose.material3.OutlinedTextField
35import androidx.compose.material3.Scaffold
40import androidx.compose.material3.Scaffold
36import androidx.compose.material3.Surface
41import androidx.compose.material3.Surface
37import androidx.compose.material3.Text
42import androidx.compose.material3.Text
38import androidx.compose.material3.TextButton
43import androidx.compose.material3.TextButton
39import androidx.compose.material3.TopAppBar
44import androidx.compose.material3.TopAppBar
45+import androidx.compose.material3.rememberModalBottomSheetState
40import androidx.compose.runtime.Composable
46import androidx.compose.runtime.Composable
41import androidx.compose.runtime.LaunchedEffect
47import androidx.compose.runtime.LaunchedEffect
42import androidx.compose.runtime.getValue
48import androidx.compose.runtime.getValue
@@ -46,12 +52,19 @@ import androidx.compose.runtime.setValue
46import androidx.compose.ui.Alignment
52import androidx.compose.ui.Alignment
47import androidx.compose.ui.Modifier
53import androidx.compose.ui.Modifier
48import androidx.compose.ui.draw.clip
54import androidx.compose.ui.draw.clip
55+import androidx.compose.ui.platform.LocalContext
49import androidx.compose.ui.text.font.FontFamily
56import androidx.compose.ui.text.font.FontFamily
50import androidx.compose.ui.text.font.FontWeight
57import androidx.compose.ui.text.font.FontWeight
58+import androidx.compose.ui.text.input.ImeAction
51import androidx.compose.ui.unit.dp
59import androidx.compose.ui.unit.dp
60+import soy.iko.crush.proto.Message
52import soy.iko.crush.proto.MessageRole
61import soy.iko.crush.proto.MessageRole
62+import soy.iko.crush.proto.PermissionRequest
63+import soy.iko.crush.proto.Session
53import soy.iko.crush.ui.ChatUiState
64import soy.iko.crush.ui.ChatUiState
54import soy.iko.crush.ui.MessageRenderer
65import soy.iko.crush.ui.MessageRenderer
66+import java.text.DateFormat
56@OptIn(ExperimentalMaterial3Api::class)
69@OptIn(ExperimentalMaterial3Api::class)
57@Composable
70@Composable
@@ -61,13 +74,14 @@ fun ChatScreen(
61 onCancel: () -> Unit,
74 onCancel: () -> Unit,
62 onNewSession: () -> Unit,
75 onNewSession: () -> Unit,
63 onPickSession: (String) -> Unit,
76 onPickSession: (String) -> Unit,
64− onGrantPermission: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
77+ onGrantPermission: (PermissionRequest, Boolean) -> Unit,
78+ onDisconnect: () -> Unit,
66 var input by remember { mutableStateOf("") }
80 var input by remember { mutableStateOf("") }
67 var showSessionPicker by remember { mutableStateOf(false) }
81 var showSessionPicker by remember { mutableStateOf(false) }
82+ var showOverflow by remember { mutableStateOf(false) }
68 val listState = rememberLazyListState()
83 val listState = rememberLazyListState()
70− // Auto-scroll to bottom when new messages arrive.
71 LaunchedEffect(state.messages.size) {
85 LaunchedEffect(state.messages.size) {
72 if (state.messages.isNotEmpty()) {
86 if (state.messages.isNotEmpty()) {
73 listState.animateScrollToItem(state.messages.lastIndex)
87 listState.animateScrollToItem(state.messages.lastIndex)
@@ -79,8 +93,12 @@ fun ChatScreen(
79 TopAppBar(
93 TopAppBar(
96+ Row(verticalAlignment = Alignment.CenterVertically) {
82− Text("Crush", fontWeight = FontWeight.Bold)
97+ Text("Crush", fontWeight = FontWeight.Bold)
98+ Spacer(Modifier.size(6.dp))
83− val subtitle = state.currentSessionId?.let { "session $it" } ?: "no session"
101+ val subtitle = state.currentSessionId?.let { "session ${it.take(8)}" } ?: "no session"
85 subtitle,
103 subtitle,
86 style = MaterialTheme.typography.labelSmall,
104 style = MaterialTheme.typography.labelSmall,
@@ -101,6 +119,30 @@ fun ChatScreen(
101 IconButton(onClick = { showSessionPicker = true }) {
119 IconButton(onClick = { showSessionPicker = true }) {
102 Icon(Icons.Filled.Add, contentDescription = "Sessions")
120 Icon(Icons.Filled.Add, contentDescription = "Sessions")
123+ IconButton(onClick = { showOverflow = true }) {
124+ Icon(Icons.Filled.MoreVert, contentDescription = "More")
127+ expanded = showOverflow,
128+ onDismissRequest = { showOverflow = false },
131+ text = { Text("Switch session") },
133+ showOverflow = false
134+ showSessionPicker = true
138+ text = { Text("Disconnect") },
140+ showOverflow = false
@@ -126,20 +168,28 @@ fun ChatScreen(
126 if (state.error != null) {
168 if (state.error != null) {
127 ErrorBanner(state.error)
169 ErrorBanner(state.error)
171+ if (state.messages.isEmpty() && !state.isBusy) {
129− LazyColumn(
178+ LazyColumn(
130− state = listState,
179+ state = listState,
131− modifier = Modifier
180+ modifier = Modifier
132− .weight(1f)
181+ .weight(1f)
133− .fillMaxWidth()
182+ .fillMaxWidth()
134− .padding(horizontal = 12.dp),
183+ .padding(horizontal = 12.dp),
135− verticalArrangement = Arrangement.spacedBy(8.dp),
184+ verticalArrangement = Arrangement.spacedBy(8.dp),
136− contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
185+ contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
138− items(state.messages, key = { it.id }) { msg ->
187+ items(state.messages, key = { it.id }) { msg ->
139− MessageBubble(msg)
188+ MessageBubble(msg)
141− if (state.isBusy && state.messages.isEmpty()) {
190+ if (state.isBusy && state.messages.isEmpty()) {
142− item { TypingIndicator() }
191+ item { TypingIndicator() }
145 PermissionQueue(
195 PermissionQueue(
@@ -167,27 +217,74 @@ fun ChatScreen(
169@Composable
219@Composable
220+private fun StatusDot(state: ChatUiState) {
222+ state.isBusy -> MaterialTheme.colorScheme.tertiary
223+ state.currentSessionId != null -> MaterialTheme.colorScheme.primary
224+ else -> MaterialTheme.colorScheme.outline
170−private fun MessageBubble(message: soy.iko.crush.proto.Message) {
235+private fun MessageBubble(message: Message) {
171 val isUser = message.role == MessageRole.user
236 val isUser = message.role == MessageRole.user
172 val bg = if (isUser) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
237 val bg = if (isUser) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
173 else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
238 else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
174 val alignment = if (isUser) Alignment.End else Alignment.Start
239 val alignment = if (isUser) Alignment.End else Alignment.Start
240+ val context = LocalContext.current
177 modifier = Modifier.fillMaxWidth(),
243 modifier = Modifier.fillMaxWidth(),
178 horizontalAlignment = alignment,
244 horizontalAlignment = alignment,
181− MessageRenderer.roleLabel(message.role),
247+ verticalAlignment = Alignment.CenterVertically,
182− style = MaterialTheme.typography.labelSmall,
248+ horizontalArrangement = Arrangement.spacedBy(6.dp),
183− color = MaterialTheme.colorScheme.onSurfaceVariant,
184 modifier = Modifier.padding(bottom = 2.dp, start = 4.dp, end = 4.dp),
249 modifier = Modifier.padding(bottom = 2.dp, start = 4.dp, end = 4.dp),
252+ MessageRenderer.roleLabel(message.role),
253+ style = MaterialTheme.typography.labelSmall,
254+ color = MaterialTheme.colorScheme.onSurfaceVariant,
256+ if (message.createdAt > 0) {
259+ style = MaterialTheme.typography.labelSmall,
260+ color = MaterialTheme.colorScheme.onSurfaceVariant,
263+ formatTimestamp(message.createdAt),
264+ style = MaterialTheme.typography.labelSmall,
265+ color = MaterialTheme.colorScheme.onSurfaceVariant,
187 modifier = Modifier
270 modifier = Modifier
188 .widthIn(max = 340.dp)
271 .widthIn(max = 340.dp)
189 .clip(RoundedCornerShape(16.dp))
272 .clip(RoundedCornerShape(16.dp))
190 .background(bg)
273 .background(bg)
275+ enabled = message.parts.any { it.type == soy.iko.crush.proto.PartType.Text },
277+ val text = MessageRenderer.renderText(message)
278+ if (text.isNotEmpty()) {
280+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE)
281+ as android.content.ClipboardManager
282+ clipboard.setPrimaryClip(
283+ android.content.ClipData.newPlainText("Crush", text),
191 .padding(12.dp),
288 .padding(12.dp),
193 val text = MessageRenderer.renderText(message)
290 val text = MessageRenderer.renderText(message)
@@ -200,7 +297,6 @@ private fun MessageBubble(message: soy.iko.crush.proto.Message) {
200 color = MaterialTheme.colorScheme.onSurface,
297 color = MaterialTheme.colorScheme.onSurface,
202 } else if (message.parts.isNotEmpty()) {
299 } else if (message.parts.isNotEmpty()) {
203− // Non-text parts (tool calls etc.) — render compactly.
205 message.parts.joinToString("\n") { MessageRenderer.renderPart(it) },
301 message.parts.joinToString("\n") { MessageRenderer.renderPart(it) },
206 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
302 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
@@ -232,6 +328,14 @@ private fun InputBar(
232 placeholder = { Text("Message Crush…") },
328 placeholder = { Text("Message Crush…") },
233 maxLines = 5,
329 maxLines = 5,
234 enabled = enabled,
330 enabled = enabled,
331+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
332+ keyboardActions = KeyboardActions(
334+ if (enabled && text.isNotBlank()) {
236 Spacer(Modifier.size(8.dp))
340 Spacer(Modifier.size(8.dp))
237 IconButton(onClick = onSend, enabled = enabled && text.isNotBlank()) {
341 IconButton(onClick = onSend, enabled = enabled && text.isNotBlank()) {
@@ -243,16 +347,14 @@ private fun InputBar(
244@Composable
348@Composable
245private fun ErrorBanner(message: String) {
349private fun ErrorBanner(message: String) {
249− .background(MaterialTheme.colorScheme.error.copy(alpha = 0.12f))
350+ Surface(color = MaterialTheme.colorScheme.errorContainer, tonalElevation = 2.dp) {
254− color = MaterialTheme.colorScheme.error,
353+ color = MaterialTheme.colorScheme.onErrorContainer,
255 style = MaterialTheme.typography.bodySmall,
354 style = MaterialTheme.typography.bodySmall,
@@ -269,10 +371,36 @@ private fun TypingIndicator() {
375+private fun EmptyConversation(modifier: Modifier = Modifier) {
377+ modifier = modifier,
378+ horizontalAlignment = Alignment.CenterHorizontally,
379+ verticalArrangement = Arrangement.Center,
383+ style = MaterialTheme.typography.displayMedium,
385+ Spacer(Modifier.height(8.dp))
387+ "Say hello to Crush",
388+ style = MaterialTheme.typography.titleMedium,
389+ color = MaterialTheme.colorScheme.onSurface,
391+ Spacer(Modifier.height(4.dp))
393+ "Send a message below to start the conversation.",
394+ style = MaterialTheme.typography.bodySmall,
395+ color = MaterialTheme.colorScheme.onSurfaceVariant,
272@Composable
400@Composable
273private fun PermissionQueue(
401private fun PermissionQueue(
274− requests: List<soy.iko.crush.proto.PermissionRequest>,
402+ requests: List<PermissionRequest>,
275− onGrant: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
403+ onGrant: (PermissionRequest, Boolean) -> Unit,
277 if (requests.isEmpty()) return
405 if (requests.isEmpty()) return
278 val req = requests.first()
406 val req = requests.first()
@@ -291,41 +419,96 @@ private fun PermissionQueue(
422+@OptIn(ExperimentalMaterial3Api::class)
294@Composable
423@Composable
295private fun SessionPickerSheet(
424private fun SessionPickerSheet(
296− sessions: List<soy.iko.crush.proto.Session>,
425+ sessions: List<Session>,
297 currentId: String?,
426 currentId: String?,
298 onPick: (String?) -> Unit,
427 onPick: (String?) -> Unit,
299 onNew: () -> Unit,
428 onNew: () -> Unit,
300 onDismiss: () -> Unit,
429 onDismiss: () -> Unit,
302− AlertDialog(
431+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
303 onDismissRequest = onDismiss,
433 onDismissRequest = onDismiss,
434+ sheetState = sheetState,
439+ .padding(horizontal = 16.dp, vertical = 8.dp),
442+ modifier = Modifier.fillMaxWidth(),
443+ horizontalArrangement = Arrangement.SpaceBetween,
444+ verticalAlignment = Alignment.CenterVertically,
304− title = { Text("Sessions") },
447+ "Sessions",
305− text = {
448+ style = MaterialTheme.typography.titleLarge,
306− Column {
449+ fontWeight = FontWeight.Bold,
451+ TextButton(onClick = onNew) { Text("New session") }
453+ Spacer(Modifier.height(8.dp))
454+ if (sessions.isEmpty()) {
457+ color = MaterialTheme.colorScheme.onSurfaceVariant,
458+ modifier = Modifier.padding(vertical = 16.dp),
307 sessions.forEach { s ->
461 sessions.forEach { s ->
308− val active = s.id == currentId
462+ SessionRow(s, isActive = s.id == currentId, onClick = { onPick(s.id) })
309− Text(
463+ HorizontalDivider()
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,
317− .clip(RoundedCornerShape(8.dp))
318− .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
321− Spacer(Modifier.height(4.dp))
323− if (sessions.isEmpty()) {
324− Text("No sessions yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
466+ Spacer(Modifier.height(16.dp))
328− confirmButton = { Button(onNew) { Text("New session") } },
468+ }
329− dismissButton = { TextButton(onDismiss) { Text("Close") } },
469+}
472+private fun SessionRow(session: Session, isActive: Boolean, onClick: () -> Unit) {
473+ val title = session.title.ifBlank { session.id.take(8) }
477+ .clickable(onClick = onClick)
478+ .padding(vertical = 12.dp, horizontal = 8.dp),
481+ modifier = Modifier.fillMaxWidth(),
482+ verticalAlignment = Alignment.CenterVertically,
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),
497+ .background(MaterialTheme.colorScheme.primary),
501+ if (session.messageCount > 0) {
503+ "${session.messageCount} messages",
504+ style = MaterialTheme.typography.labelSmall,
505+ color = MaterialTheme.colorScheme.onSurfaceVariant,
511+private fun formatTimestamp(epochSeconds: Long): String {
512+ val timeMs = epochSeconds * 1000L
513+ return DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(timeMs))