From 4958ca84299d08e3c6eab492c52bca4dd5738d69 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Tue, 23 Jun 2026 13:57:28 +0200 Subject: [PATCH] feat: single-row input, pills inside card, new-chat button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../dev/kaizen/app/chat/ChatComponents.kt | 129 +++++++++++------- .../java/dev/kaizen/app/chat/ChatScreen.kt | 47 ++++--- 2 files changed, 113 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index a7052bb..784e68e 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -737,6 +737,7 @@ fun ChatInput( pendingFiles: List = 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) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 58d2a00..0855a90 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -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)) }