@@ -1,7 +1,6 @@
11package soy.iko.crush.ui.screens
22
33import androidx.compose.foundation.background
4−import androidx.compose.foundation.clickable
54import androidx.compose.foundation.layout.Arrangement
65import androidx.compose.foundation.layout.Box
76import androidx.compose.foundation.layout.Column
@@ -16,33 +15,28 @@ import androidx.compose.foundation.layout.widthIn
1615import androidx.compose.foundation.lazy.LazyColumn
1716import androidx.compose.foundation.lazy.items
1817import androidx.compose.foundation.lazy.rememberLazyListState
19−import androidx.compose.foundation.shape.CircleShape
2018import androidx.compose.foundation.shape.RoundedCornerShape
21−import androidx.compose.foundation.text.KeyboardActions
22−import androidx.compose.foundation.text.KeyboardOptions
2319import androidx.compose.material.icons.Icons
2420import androidx.compose.material.icons.automirrored.filled.Send
2521import androidx.compose.material.icons.filled.Add
26−import androidx.compose.material.icons.filled.MoreVert
2722import androidx.compose.material.icons.filled.Stop
2823import androidx.compose.material3.AlertDialog
2924import androidx.compose.material3.Button
25+import androidx.compose.material3.Card
26+import androidx.compose.material3.CardDefaults
3027import androidx.compose.material3.CircularProgressIndicator
31−import androidx.compose.material3.DropdownMenu
32−import androidx.compose.material3.DropdownMenuItem
3328import androidx.compose.material3.ExperimentalMaterial3Api
29+import androidx.compose.material3.FloatingActionButton
3430import androidx.compose.material3.HorizontalDivider
3531import androidx.compose.material3.Icon
3632import androidx.compose.material3.IconButton
3733import androidx.compose.material3.MaterialTheme
38−import androidx.compose.material3.ModalBottomSheet
3934import androidx.compose.material3.OutlinedTextField
4035import androidx.compose.material3.Scaffold
4136import androidx.compose.material3.Surface
4237import androidx.compose.material3.Text
4338import androidx.compose.material3.TextButton
4439import androidx.compose.material3.TopAppBar
45−import androidx.compose.material3.rememberModalBottomSheetState
4640import androidx.compose.runtime.Composable
4741import androidx.compose.runtime.LaunchedEffect
4842import androidx.compose.runtime.getValue
@@ -52,19 +46,12 @@ import androidx.compose.runtime.setValue
5246import androidx.compose.ui.Alignment
5347import androidx.compose.ui.Modifier
5448import androidx.compose.ui.draw.clip
55−import androidx.compose.ui.platform.LocalContext
5649import androidx.compose.ui.text.font.FontFamily
5750import androidx.compose.ui.text.font.FontWeight
58−import androidx.compose.ui.text.input.ImeAction
5951import androidx.compose.ui.unit.dp
60−import soy.iko.crush.proto.Message
6152import soy.iko.crush.proto.MessageRole
62−import soy.iko.crush.proto.PermissionRequest
63−import soy.iko.crush.proto.Session
6453import soy.iko.crush.ui.ChatUiState
6554import soy.iko.crush.ui.MessageRenderer
66−import java.text.DateFormat
67−import java.util.Date
6855
6956@OptIn(ExperimentalMaterial3Api::class)
7057@Composable
@@ -74,14 +61,13 @@ fun ChatScreen(
7461 onCancel: () -> Unit,
7562 onNewSession: () -> Unit,
7663 onPickSession: (String) -> Unit,
77− onGrantPermission: (PermissionRequest, Boolean) -> Unit,
78− onDisconnect: () -> Unit,
64+ onGrantPermission: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
7965) {
8066 var input by remember { mutableStateOf("") }
8167 var showSessionPicker by remember { mutableStateOf(false) }
82− var showOverflow by remember { mutableStateOf(false) }
8368 val listState = rememberLazyListState()
8469
70+ // Auto-scroll to bottom when new messages arrive.
8571 LaunchedEffect(state.messages.size) {
8672 if (state.messages.isNotEmpty()) {
8773 listState.animateScrollToItem(state.messages.lastIndex)
@@ -93,12 +79,8 @@ fun ChatScreen(
9379 TopAppBar(
9480 title = {
9581 Column {
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"
82+ Text("Crush", fontWeight = FontWeight.Bold)
83+ val subtitle = state.currentSessionId?.let { "session $it" } ?: "no session"
10284 Text(
10385 subtitle,
10486 style = MaterialTheme.typography.labelSmall,
@@ -119,30 +101,6 @@ fun ChatScreen(
119101 IconButton(onClick = { showSessionPicker = true }) {
120102 Icon(Icons.Filled.Add, contentDescription = "Sessions")
121103 }
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− }
146104 },
147105 )
148106 },
@@ -168,28 +126,20 @@ fun ChatScreen(
168126 if (state.error != null) {
169127 ErrorBanner(state.error)
170128 }
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− }
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() }
193143 }
194144 }
195145 PermissionQueue(
@@ -217,74 +167,27 @@ fun ChatScreen(
217167}
218168
219169@Composable
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) {
170+private fun MessageBubble(message: soy.iko.crush.proto.Message) {
236171 val isUser = message.role == MessageRole.user
237172 val bg = if (isUser) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
238173 else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
239174 val alignment = if (isUser) Alignment.End else Alignment.Start
240− val context = LocalContext.current
241175
242176 Column(
243177 modifier = Modifier.fillMaxWidth(),
244178 horizontalAlignment = alignment,
245179 ) {
246− Row(
247− verticalAlignment = Alignment.CenterVertically,
248− horizontalArrangement = Arrangement.spacedBy(6.dp),
180+ Text(
181+ MessageRenderer.roleLabel(message.role),
182+ style = MaterialTheme.typography.labelSmall,
183+ color = MaterialTheme.colorScheme.onSurfaceVariant,
249184 modifier = Modifier.padding(bottom = 2.dp, start = 4.dp, end = 4.dp),
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− }
185+ )
269186 Box(
270187 modifier = Modifier
271188 .widthIn(max = 340.dp)
272189 .clip(RoundedCornerShape(16.dp))
273190 .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− )
288191 .padding(12.dp),
289192 ) {
290193 val text = MessageRenderer.renderText(message)
@@ -297,6 +200,7 @@ private fun MessageBubble(message: Message) {
297200 color = MaterialTheme.colorScheme.onSurface,
298201 )
299202 } else if (message.parts.isNotEmpty()) {
203+ // Non-text parts (tool calls etc.) — render compactly.
300204 Text(
301205 message.parts.joinToString("\n") { MessageRenderer.renderPart(it) },
302206 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
@@ -328,14 +232,6 @@ private fun InputBar(
328232 placeholder = { Text("Message Crush…") },
329233 maxLines = 5,
330234 enabled = enabled,
331− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
332− keyboardActions = KeyboardActions(
333− onSend = {
334− if (enabled && text.isNotBlank()) {
335− onSend()
336− }
337− },
338− ),
339235 )
340236 Spacer(Modifier.size(8.dp))
341237 IconButton(onClick = onSend, enabled = enabled && text.isNotBlank()) {
@@ -347,14 +243,16 @@ private fun InputBar(
347243
348244@Composable
349245private fun ErrorBanner(message: String) {
350− Surface(color = MaterialTheme.colorScheme.errorContainer, tonalElevation = 2.dp) {
246+ Box(
247+ modifier = Modifier
248+ .fillMaxWidth()
249+ .background(MaterialTheme.colorScheme.error.copy(alpha = 0.12f))
250+ .padding(8.dp),
251+ ) {
351252 Text(
352253 message,
353− color = MaterialTheme.colorScheme.onErrorContainer,
254+ color = MaterialTheme.colorScheme.error,
354255 style = MaterialTheme.typography.bodySmall,
355− modifier = Modifier
356− .fillMaxWidth()
357− .padding(12.dp),
358256 )
359257 }
360258}
@@ -371,36 +269,10 @@ private fun TypingIndicator() {
371269 }
372270}
373271
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−
400272@Composable
401273private fun PermissionQueue(
402− requests: List<PermissionRequest>,
403− onGrant: (PermissionRequest, Boolean) -> Unit,
274+ requests: List<soy.iko.crush.proto.PermissionRequest>,
275+ onGrant: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
404276) {
405277 if (requests.isEmpty()) return
406278 val req = requests.first()
@@ -419,96 +291,41 @@ private fun PermissionQueue(
419291 )
420292}
421293
422−@OptIn(ExperimentalMaterial3Api::class)
423294@Composable
424295private fun SessionPickerSheet(
425− sessions: List<Session>,
296+ sessions: List<soy.iko.crush.proto.Session>,
426297 currentId: String?,
427298 onPick: (String?) -> Unit,
428299 onNew: () -> Unit,
429300 onDismiss: () -> Unit,
430301) {
431− val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
432− ModalBottomSheet(
302+ AlertDialog(
433303 onDismissRequest = onDismiss,
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 {
304+ title = { Text("Sessions") },
305+ text = {
306+ Column {
461307 sessions.forEach { s ->
462− SessionRow(s, isActive = s.id == currentId, onClick = { onPick(s.id) })
463− HorizontalDivider()
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)
464325 }
465326 }
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))
327+ },
328+ confirmButton = { Button(onNew) { Text("New session") } },
329+ dismissButton = { TextButton(onDismiss) { Text("Close") } },
330+ )
514331}