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