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(),
onAddClick: () -> Unit = {},
onRemoveFile: (String) -> Unit = {},
pillsContent: @Composable (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val cs = MaterialTheme.colorScheme
@ -746,76 +747,112 @@ fun ChatInput(
val hasContent = value.isNotBlank() || pendingFiles.any { it.uploaded != null }
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(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = KaizenShapes.pill,
tintAlpha = if (isDark) 0.82f else 0.88f,
shape = KaizenShapes.lg,
tintAlpha = if (isDark) 0.84f else 0.90f,
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) {
// Recording state — live waveform replaces the text field
VoiceWaveform(amplitude = recordingAmplitude)
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.weight(1f))
Box(
Modifier.size(42.dp).clip(KaizenShapes.circle)
.background(KaizenSemanticColors.error)
.clickable(onClick = onMicClick),
contentAlignment = Alignment.Center,
) {
Icon(KaizenIcons.Square, stringResource(R.string.chat_stop), tint = Color.White, modifier = Modifier.size(16.dp))
Column(Modifier.padding(horizontal = 14.dp)) {
VoiceWaveform(amplitude = recordingAmplitude)
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.weight(1f))
Box(
Modifier.size(42.dp).clip(KaizenShapes.circle)
.background(KaizenSemanticColors.error)
.clickable(onClick = onMicClick),
contentAlignment = Alignment.Center,
) {
Icon(KaizenIcons.Square, stringResource(R.string.chat_stop), tint = Color.White, modifier = Modifier.size(16.dp))
}
Spacer(Modifier.weight(1f))
}
Spacer(Modifier.weight(1f))
}
} 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()) {
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),
) {
pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
}
}
Box(Modifier.fillMaxWidth().heightIn(min = 48.dp), contentAlignment = Alignment.CenterStart) {
if (value.isEmpty()) Text(
stringResource(R.string.chat_input_placeholder),
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 16.sp,
fontWeight = FontWeight.W300,
)
BasicTextField(
value = value, onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W300),
cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
// Single-row input: Plus | TextField | Mic | Send/Call
Row(
Modifier.fillMaxWidth().padding(horizontal = 10.dp),
verticalAlignment = Alignment.Bottom,
) {
Box(
Modifier.size(42.dp).clip(KaizenShapes.circle).background(iconBg).clickable(onClick = onAddClick),
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(22.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))
Icon(KaizenIcons.Plus, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(21.dp))
}
Spacer(Modifier.weight(1f))
Box(
Modifier.weight(1f).heightIn(min = 38.dp).padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty()) Text(
stringResource(R.string.chat_input_placeholder),
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
fontSize = 16.sp,
fontWeight = FontWeight.W300,
)
BasicTextField(
value = value, onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W300),
cursorBrush = SolidColor(accent.primary), maxLines = 4, modifier = Modifier.fillMaxWidth(),
)
}
if (pillsContent != null) {
Box(
Modifier.size(38.dp).clip(KaizenShapes.circle).clickable { showPills = !showPills },
contentAlignment = Alignment.Center,
) {
Icon(
KaizenIcons.SlidersHorizontal, null,
tint = if (showPills) accent.primary else cs.onSurfaceVariant.copy(alpha = 0.55f),
modifier = Modifier.size(19.dp),
)
}
}
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) {
StopButton(onClick = onStop)

View file

@ -863,7 +863,7 @@ fun ChatScreen(
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier),
contentPadding = PaddingValues(
top = 100.dp,
bottom = 260.dp,
bottom = 140.dp,
start = 16.dp,
end = 16.dp
),
@ -1035,6 +1035,18 @@ fun ChatScreen(
Spacer(Modifier.weight(1f))
val ctxLen = models.find { it.id == displayModel }?.context_length ?: 1_000_000
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,22 +1101,7 @@ fun ChatScreen(
)
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
) {
Spacer(Modifier.height(if (imeVisible) 6.dp else 24.dp))
ModePillsRow(
activeMode = activeMode,
onToggle = { mode ->
activeMode = if (activeMode == mode) null else mode
haptics.tick()
},
reasoningPreset = reasoningPreset,
onReasoningChange = { reasoningPreset = it; haptics.tick() },
samplingPrefs = samplingPrefs,
onSamplingChange = { samplingPrefs = it },
currentModelId = session.config?.model ?: "",
webSearchProvider = webSearchProvider,
onWebSearchProviderChange = { webSearchProvider = it; haptics.tick() },
)
Spacer(Modifier.height(if (imeVisible) 6.dp else 10.dp))
Spacer(Modifier.height(if (imeVisible) 4.dp else 12.dp))
ChatInput(
value = input,
onValueChange = { input = it },
@ -1118,6 +1115,22 @@ fun ChatScreen(
pendingFiles = pendingFiles,
onAddClick = { showAttachMenu = !showAttachMenu },
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
pillsContent = {
ModePillsRow(
activeMode = activeMode,
onToggle = { mode ->
activeMode = if (activeMode == mode) null else mode
haptics.tick()
},
reasoningPreset = reasoningPreset,
onReasoningChange = { reasoningPreset = it; haptics.tick() },
samplingPrefs = samplingPrefs,
onSamplingChange = { samplingPrefs = it },
currentModelId = session.config?.model ?: "",
webSearchProvider = webSearchProvider,
onWebSearchProviderChange = { webSearchProvider = it; haptics.tick() },
)
},
)
Spacer(Modifier.height(if (imeVisible) 4.dp else 16.dp))
}