feat: single-row input, pills inside card, new-chat button

ChatInput redesigned: single row (Plus | Text | Tune | Mic | Send).
Pills hidden by default, expandable via tune icon with slide animation
inside the same GlassSurface card. Shape lg (28dp) instead of pill.

ModePillsRow moved from separate floating row into ChatInput as
pillsContent lambda. Bottom dock scrim simplified.

New Chat button (PenLine icon) added to top bar next to TokenBadge.

LazyColumn bottom padding 260→140dp (dock is now smaller).
This commit is contained in:
Bruno Deanoz 2026-06-23 13:57:28 +02:00
parent ad98b9f499
commit 4958ca8429
2 changed files with 113 additions and 63 deletions

View file

@ -737,6 +737,7 @@ fun ChatInput(
pendingFiles: List<PendingFile> = emptyList(), pendingFiles: List<PendingFile> = emptyList(),
onAddClick: () -> Unit = {}, onAddClick: () -> Unit = {},
onRemoveFile: (String) -> Unit = {}, onRemoveFile: (String) -> Unit = {},
pillsContent: @Composable (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
@ -746,20 +747,20 @@ fun ChatInput(
val hasContent = value.isNotBlank() || pendingFiles.any { it.uploaded != null } val hasContent = value.isNotBlank() || pendingFiles.any { it.uploaded != null }
val canSend = enabled && hasContent && pendingFiles.none { it.uploading } val canSend = enabled && hasContent && pendingFiles.none { it.uploading }
val iconBg = if (isDark) Color.White.copy(alpha = 0.07f) else Color.Black.copy(alpha = 0.05f) var showPills by remember { mutableStateOf(false) }
GlassSurface( GlassSurface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
shape = KaizenShapes.pill, shape = KaizenShapes.lg,
tintAlpha = if (isDark) 0.82f else 0.88f, tintAlpha = if (isDark) 0.84f else 0.90f,
shadowStack = KaizenShadows.level3, shadowStack = KaizenShadows.level3,
cornerRadius = 32.dp, cornerRadius = 28.dp,
) { ) {
Column(Modifier.padding(horizontal = 14.dp, vertical = 14.dp)) { Column(Modifier.padding(vertical = 10.dp)) {
if (isRecording) { if (isRecording) {
// Recording state — live waveform replaces the text field Column(Modifier.padding(horizontal = 14.dp)) {
VoiceWaveform(amplitude = recordingAmplitude) VoiceWaveform(amplitude = recordingAmplitude)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -774,48 +775,84 @@ fun ChatInput(
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
} }
}
} else { } else {
// Expandable pills row
if (pillsContent != null) {
androidx.compose.animation.AnimatedVisibility(
visible = showPills,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
Column {
pillsContent()
Spacer(Modifier.height(6.dp))
Box(
Modifier.fillMaxWidth().padding(horizontal = 14.dp).height(0.5.dp)
.background(cs.onSurfaceVariant.copy(alpha = 0.10f))
)
Spacer(Modifier.height(8.dp))
}
}
}
if (pendingFiles.isNotEmpty()) { if (pendingFiles.isNotEmpty()) {
Row( Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 10.dp), Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 14.dp, end = 14.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) } pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
} }
} }
Box(Modifier.fillMaxWidth().heightIn(min = 48.dp), contentAlignment = Alignment.CenterStart) { // Single-row input: Plus | TextField | Mic | Send/Call
Row(
Modifier.fillMaxWidth().padding(horizontal = 10.dp),
verticalAlignment = Alignment.Bottom,
) {
Box(
Modifier.size(38.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick),
contentAlignment = Alignment.Center,
) {
Icon(KaizenIcons.Plus, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(21.dp))
}
Box(
Modifier.weight(1f).heightIn(min = 38.dp).padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty()) Text( if (value.isEmpty()) Text(
stringResource(R.string.chat_input_placeholder), stringResource(R.string.chat_input_placeholder),
color = cs.onSurfaceVariant.copy(alpha = 0.5f), color = cs.onSurfaceVariant.copy(alpha = 0.45f),
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W300, fontWeight = FontWeight.W300,
) )
BasicTextField( BasicTextField(
value = value, onValueChange = onValueChange, value = value, onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W300), textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W300),
cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(), cursorBrush = SolidColor(accent.primary), maxLines = 4, modifier = Modifier.fillMaxWidth(),
) )
} }
Spacer(Modifier.height(10.dp)) if (pillsContent != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box( Box(
Modifier.size(42.dp).clip(KaizenShapes.circle).background(iconBg).clickable(onClick = onAddClick), Modifier.size(38.dp).clip(KaizenShapes.circle).clickable { showPills = !showPills },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon(KaizenIcons.Plus, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp)) Icon(
KaizenIcons.SlidersHorizontal, null,
tint = if (showPills) accent.primary else cs.onSurfaceVariant.copy(alpha = 0.55f),
modifier = Modifier.size(19.dp),
)
} }
Spacer(Modifier.width(8.dp))
Box(
Modifier.size(42.dp).clip(KaizenShapes.circle).background(iconBg).clickable(onClick = onMicClick),
contentAlignment = Alignment.Center,
) {
Icon(KaizenIcons.Mic, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
} }
Spacer(Modifier.weight(1f)) Box(
Modifier.size(38.dp).clip(KaizenShapes.circle).clickable(onClick = onMicClick),
contentAlignment = Alignment.Center,
) {
Icon(KaizenIcons.Mic, stringResource(R.string.chat_mic_permission), tint = cs.onSurfaceVariant, modifier = Modifier.size(21.dp))
}
if (isStreaming) { if (isStreaming) {
StopButton(onClick = onStop) StopButton(onClick = onStop)

View file

@ -863,7 +863,7 @@ fun ChatScreen(
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier), .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = 100.dp, top = 100.dp,
bottom = 260.dp, bottom = 140.dp,
start = 16.dp, start = 16.dp,
end = 16.dp end = 16.dp
), ),
@ -1035,6 +1035,18 @@ fun ChatScreen(
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
val ctxLen = models.find { it.id == displayModel }?.context_length ?: 1_000_000 val ctxLen = models.find { it.id == displayModel }?.context_length ?: 1_000_000
TokenBadge(used = usedTokens, contextLength = ctxLen) TokenBadge(used = usedTokens, contextLength = ctxLen)
Spacer(Modifier.width(8.dp))
Box(
Modifier.size(36.dp).clip(KaizenShapes.circle)
.clickable { haptics.tick(); newChat() },
contentAlignment = Alignment.Center,
) {
Icon(
KaizenIcons.PenLine, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(17.dp),
)
}
} }
} }
@ -1089,7 +1101,21 @@ fun ChatScreen(
) )
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier) .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
) { ) {
Spacer(Modifier.height(if (imeVisible) 6.dp else 24.dp)) Spacer(Modifier.height(if (imeVisible) 4.dp else 12.dp))
ChatInput(
value = input,
onValueChange = { input = it },
onSend = { send(input) },
enabled = !isStreaming,
isStreaming = isStreaming,
onStop = { streamJob?.cancel() },
isRecording = isRecording,
recordingAmplitude = recordingAmplitude,
onMicClick = { toggleRecording() },
pendingFiles = pendingFiles,
onAddClick = { showAttachMenu = !showAttachMenu },
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
pillsContent = {
ModePillsRow( ModePillsRow(
activeMode = activeMode, activeMode = activeMode,
onToggle = { mode -> onToggle = { mode ->
@ -1104,20 +1130,7 @@ fun ChatScreen(
webSearchProvider = webSearchProvider, webSearchProvider = webSearchProvider,
onWebSearchProviderChange = { webSearchProvider = it; haptics.tick() }, onWebSearchProviderChange = { webSearchProvider = it; haptics.tick() },
) )
Spacer(Modifier.height(if (imeVisible) 6.dp else 10.dp)) },
ChatInput(
value = input,
onValueChange = { input = it },
onSend = { send(input) },
enabled = !isStreaming,
isStreaming = isStreaming,
onStop = { streamJob?.cancel() },
isRecording = isRecording,
recordingAmplitude = recordingAmplitude,
onMicClick = { toggleRecording() },
pendingFiles = pendingFiles,
onAddClick = { showAttachMenu = !showAttachMenu },
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
) )
Spacer(Modifier.height(if (imeVisible) 4.dp else 16.dp)) Spacer(Modifier.height(if (imeVisible) 4.dp else 16.dp))
} }