Android: ChatGPT-style chat composer with integrated PTT

Redesigned ChatComposer with Telegram/ChatGPT-style layout:
- Hamburger menu on left (session, thinking level, attach, refresh)
- Rounded text input field in center
- Right button transforms:
  - Empty: Mini orb (PTT) with mic icon
  - Has text: Send button (arrow up)
  - Running: Stop button
- Haptic feedback on orb tap
- Compact connection status pill below
- Talk overlay shows centered when voice active
This commit is contained in:
yishai 2026-01-28 21:58:18 +02:00
parent 9c059906fe
commit 40a5b0ce1e
4 changed files with 269 additions and 134 deletions

View File

@ -267,43 +267,16 @@ fun RootScreen(viewModel: MainViewModel) {
}
}
// Chat overlay + PTT orb at bottom
Popup(alignment = Alignment.BottomCenter, properties = PopupProperties(focusable = false)) {
Column(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Chat messages above the orb
val messages by viewModel.chatMessages.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val streamingText by viewModel.chatStreamingAssistantText.collectAsState()
if (messages.isNotEmpty() || pendingRunCount > 0 || pendingToolCalls.isNotEmpty() || !streamingText.isNullOrBlank()) {
com.clawdbot.android.ui.chat.ChatMessageListCard(
messages = messages,
pendingRunCount = pendingRunCount,
pendingToolCalls = pendingToolCalls,
streamingAssistantText = streamingText,
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
.heightIn(max = 400.dp)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
// PTT orb
// PTT orb overlay (only show when talk mode is active, for visual feedback)
if (talkEnabled) {
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
TalkOrbOverlay(
seamColor = seamColor,
statusText = talkStatusText,
isListening = talkIsListening,
isSpeaking = talkIsSpeaking,
isActive = talkEnabled,
onTap = { viewModel.setTalkEnabled(!talkEnabled) },
isActive = true,
onTap = { viewModel.setTalkEnabled(false) },
)
}
}

View File

@ -79,7 +79,7 @@ fun TalkOrbOverlay(
Box(contentAlignment = Alignment.Center) {
Canvas(
modifier = Modifier
.size(200.dp)
.size(280.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null, // No ripple - the orb itself is the feedback

View File

@ -1,31 +1,47 @@
package com.clawdbot.android.ui.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -36,8 +52,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.clawdbot.android.chat.ChatSessionEntry
@Composable
@ -50,6 +73,8 @@ fun ChatComposer(
pendingRunCount: Int,
errorText: String?,
attachments: List<PendingImageAttachment>,
seamColor: Color = Color(0xFF3B82F6),
talkEnabled: Boolean = false,
onPickImages: () -> Unit,
onRemoveAttachment: (id: String) -> Unit,
onSetThinkingLevel: (level: String) -> Unit,
@ -57,132 +82,264 @@ fun ChatComposer(
onRefresh: () -> Unit,
onAbort: () -> Unit,
onSend: (text: String) -> Unit,
onToggleTalk: () -> Unit = {},
) {
var input by rememberSaveable { mutableStateOf("") }
var showMenu by remember { mutableStateOf(false) }
var showThinkingMenu by remember { mutableStateOf(false) }
var showSessionMenu by remember { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val currentSessionLabel =
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
val hasText = input.trim().isNotEmpty()
val canSend = pendingRunCount == 0 && (hasText || attachments.isNotEmpty()) && healthOk
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Error text if any
if (!errorText.isNullOrBlank()) {
Text(
text = errorText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
maxLines = 2,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
// Attachments strip
if (attachments.isNotEmpty()) {
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
}
// Main input row: [Menu] [TextField] [Orb/Send]
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Menu button
Box {
FilledTonalButton(
onClick = { showSessionMenu = true },
contentPadding = ButtonDefaults.ContentPadding,
IconButton(
onClick = { showMenu = true },
modifier = Modifier.size(44.dp),
) {
Text("Session: $currentSessionLabel")
Icon(
Icons.Default.Menu,
contentDescription = "Menu",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
for (entry in sessionOptions) {
DropdownMenuItem(
text = { Text(entry.displayName ?: entry.key) },
onClick = {
onSelectSession(entry.key)
showSessionMenu = false
},
trailingIcon = {
if (entry.key == sessionKey) {
Text("")
} else {
Spacer(modifier = Modifier.width(10.dp))
}
},
)
}
}
}
Box {
FilledTonalButton(
onClick = { showThinkingMenu = true },
contentPadding = ButtonDefaults.ContentPadding,
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
Text("Thinking: ${thinkingLabel(thinkingLevel)}")
}
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
// Session selector
DropdownMenuItem(
text = { Text("Session: $currentSessionLabel") },
onClick = {
showMenu = false
showSessionMenu = true
},
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
)
// Thinking level
DropdownMenuItem(
text = { Text("Thinking: ${thinkingLabel(thinkingLevel)}") },
onClick = {
showMenu = false
showThinkingMenu = true
},
leadingIcon = {
Text("🧠", style = MaterialTheme.typography.bodyLarge)
},
)
HorizontalDivider()
// Attach file
DropdownMenuItem(
text = { Text("Attach image") },
onClick = {
showMenu = false
onPickImages()
},
leadingIcon = { Icon(Icons.Default.AttachFile, contentDescription = null) },
)
// Refresh
DropdownMenuItem(
text = { Text("Refresh") },
onClick = {
showMenu = false
onRefresh()
},
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
)
}
}
Spacer(modifier = Modifier.weight(1f))
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
// Session menu (separate dropdown)
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
for (entry in sessionOptions) {
DropdownMenuItem(
text = { Text(entry.displayName ?: entry.key) },
onClick = {
onSelectSession(entry.key)
showSessionMenu = false
},
trailingIcon = {
if (entry.key == sessionKey) Text("") else Spacer(Modifier.width(10.dp))
},
)
}
}
FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) {
Icon(Icons.Default.AttachFile, contentDescription = "Add image")
// Thinking menu (separate dropdown)
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
}
}
if (attachments.isNotEmpty()) {
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
}
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Message Clawd…") },
minLines = 2,
maxLines = 6,
)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
Spacer(modifier = Modifier.weight(1f))
// Text field
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 4.dp),
) {
BasicTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
textStyle = TextStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = 16.sp,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
maxLines = 4,
decorationBox = { innerTextField ->
Box {
if (input.isEmpty()) {
Text(
"Message Clawd…",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
}
innerTextField()
}
},
)
}
// Right button: Abort / Send / Orb (PTT)
if (pendingRunCount > 0) {
FilledTonalIconButton(
// Abort button when running
IconButton(
onClick = onAbort,
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = Color(0x33E74C3C),
contentColor = Color(0xFFE74C3C),
),
modifier = Modifier.size(44.dp),
) {
Icon(Icons.Default.Stop, contentDescription = "Abort")
Icon(
Icons.Default.Stop,
contentDescription = "Abort",
tint = Color(0xFFE74C3C),
)
}
} else {
FilledTonalIconButton(onClick = {
val text = input
input = ""
onSend(text)
}, enabled = canSend) {
} else if (hasText || attachments.isNotEmpty()) {
// Send button when there's content
FilledTonalIconButton(
onClick = {
val text = input
input = ""
onSend(text)
},
enabled = canSend,
modifier = Modifier.size(44.dp),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = seamColor,
contentColor = Color.White,
),
) {
Icon(Icons.Default.ArrowUpward, contentDescription = "Send")
}
} else {
// Mini orb (PTT) when empty
MiniOrb(
seamColor = seamColor,
isActive = talkEnabled,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleTalk()
},
)
}
}
if (!errorText.isNullOrBlank()) {
Text(
text = errorText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
maxLines = 2,
)
}
}
// Connection status pill (compact)
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
}
}
@Composable
private fun MiniOrb(
seamColor: Color,
isActive: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val alpha = if (isActive) 1f else 0.6f
Box(
modifier = modifier
.size(44.dp)
.clip(CircleShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Canvas(modifier = Modifier.size(36.dp)) {
val center = this.center
val radius = size.minDimension / 2
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
seamColor.copy(alpha = 0.9f * alpha),
seamColor.copy(alpha = 0.4f * alpha),
Color.Black.copy(alpha = 0.3f * alpha),
),
center = center,
radius = radius * 1.2f,
),
radius = radius,
center = center,
)
}
// Mic icon overlay
Icon(
Icons.Default.Mic,
contentDescription = if (isActive) "Listening" else "Tap to talk",
tint = Color.White.copy(alpha = alpha),
modifier = Modifier.size(20.dp),
)
}
}
@ -190,19 +347,18 @@ fun ChatComposer(
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.7f),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
modifier = Modifier.size(7.dp),
shape = androidx.compose.foundation.shape.CircleShape,
modifier = Modifier.size(6.dp),
shape = CircleShape,
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
) {}
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
Text(
if (healthOk) "Connected" else "Connecting…",
style = MaterialTheme.typography.labelSmall,
@ -276,9 +432,9 @@ private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1)
FilledTonalIconButton(
onClick = onRemove,
modifier = Modifier.size(30.dp),
modifier = Modifier.size(24.dp),
) {
Text("×")
Text("×", fontSize = 14.sp)
}
}
}

View File

@ -38,6 +38,9 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
val talkEnabled by viewModel.talkEnabled.collectAsState()
val seamColor = remember(seamColorArgb) { androidx.compose.ui.graphics.Color(seamColorArgb) }
LaunchedEffect(mainSessionKey) {
viewModel.loadChat(mainSessionKey)
@ -92,6 +95,8 @@ fun ChatSheetContent(viewModel: MainViewModel) {
pendingRunCount = pendingRunCount,
errorText = errorText,
attachments = attachments,
seamColor = seamColor,
talkEnabled = talkEnabled,
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
@ -101,6 +106,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
viewModel.refreshChatSessions(limit = 200)
},
onAbort = { viewModel.abortChat() },
onToggleTalk = { viewModel.setTalkEnabled(!talkEnabled) },
onSend = { text ->
val outgoing =
attachments.map { att ->