diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantManager.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantManager.kt index 28995ae11..3048c3554 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantManager.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantManager.kt @@ -11,6 +11,7 @@ import io.picoclaw.android.feature.chat.voice.CameraCaptureManager import io.picoclaw.android.feature.chat.voice.SpeechRecognizerWrapper import io.picoclaw.android.feature.chat.voice.SttResult import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper +import io.picoclaw.android.feature.chat.voice.ChatTurn import io.picoclaw.android.feature.chat.voice.VoiceModeState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -50,7 +51,7 @@ class AssistantManager( if (loopJob?.isActive == true) return parentScope = scope _state.update { - VoiceModeState(isActive = true, phase = VoicePhase.LISTENING, isCameraActive = it.isCameraActive) + VoiceModeState(isActive = true, phase = VoicePhase.LISTENING, isCameraActive = it.isCameraActive, chatHistory = it.chatHistory) } loopJob = scope.launch { voiceLoop() @@ -75,7 +76,7 @@ class AssistantManager( loopJob = null _state.update { - VoiceModeState(isActive = true, phase = VoicePhase.LISTENING, isCameraActive = it.isCameraActive) + VoiceModeState(isActive = true, phase = VoicePhase.LISTENING, isCameraActive = it.isCameraActive, chatHistory = it.chatHistory) } loopJob = scope.launch { voiceLoop() } } @@ -123,7 +124,7 @@ class AssistantManager( select { userTextChannel.onReceive { text -> if (!text.isNullOrBlank()) { - _state.update { it.copy(phase = VoicePhase.SENDING, recognizedText = text) } + _state.update { it.copy(phase = VoicePhase.SENDING, recognizedText = text, chatHistory = it.chatHistory + ChatTurn("user", text)) } try { val base64Images = if (_state.value.isCameraActive) { captureAndEncode() @@ -209,11 +210,15 @@ class AssistantManager( private suspend fun speakAndDrain(firstContent: String, speechQueue: Channel) { _state.update { it.copy(phase = VoicePhase.SPEAKING, responseText = firstContent) } ttsWrapper.speak(firstContent) + val chunks = mutableListOf(firstContent) while (true) { val next = speechQueue.tryReceive().getOrNull() ?: break _state.update { it.copy(responseText = next) } ttsWrapper.speak(next) + chunks.add(next) } + val fullResponse = chunks.joinToString("\n") + _state.update { it.copy(chatHistory = it.chatHistory + ChatTurn("assistant", fullResponse)) } } private suspend fun drainSpeechQueue(speechQueue: Channel) { diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantPillBar.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantPillBar.kt index 93501c4c0..3a3c55fff 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantPillBar.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantPillBar.kt @@ -16,6 +16,7 @@ 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 @@ -23,10 +24,14 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -34,7 +39,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -51,6 +58,7 @@ import io.picoclaw.android.core.ui.theme.GradientCyan import io.picoclaw.android.core.ui.theme.TextPrimary import io.picoclaw.android.core.ui.theme.TextSecondary import io.picoclaw.android.feature.chat.voice.CameraCaptureManager +import io.picoclaw.android.feature.chat.voice.ChatTurn import io.picoclaw.android.feature.chat.voice.VoiceModeState import com.composables.icons.lucide.R as LucideR import kotlin.math.PI @@ -78,6 +86,8 @@ fun AssistantPillBar( val interruptable = state.phase != VoicePhase.LISTENING && state.phase != VoicePhase.IDLE + var historyExpanded by remember { mutableStateOf(false) } + Column( modifier = modifier .fillMaxWidth() @@ -135,14 +145,6 @@ fun AssistantPillBar( ) ) ) - .then( - if (interruptable) { - Modifier.clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { onInterrupt() } - } else Modifier - ) .animateContentSize() ) { Column( @@ -152,17 +154,45 @@ fun AssistantPillBar( ) { // Expanded content: response text area AnimatedVisibility( - visible = isExpanded && (state.responseText.isNotEmpty() || state.errorMessage != null) + visible = state.chatHistory.isNotEmpty() || (isExpanded && (state.responseText.isNotEmpty() || state.errorMessage != null)) ) { - Column(modifier = Modifier.padding(bottom = 12.dp)) { - if (state.responseText.isNotEmpty()) { - Text( - text = state.responseText, - style = MaterialTheme.typography.bodyMedium, - color = TextPrimary, - maxLines = 6, - overflow = TextOverflow.Ellipsis - ) + Column( + modifier = Modifier + .padding(bottom = 12.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { historyExpanded = !historyExpanded } + ) { + if (historyExpanded) { + Column( + modifier = Modifier + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + for (turn in state.chatHistory) { + ChatBubble(turn) + } + if (state.responseText.isNotEmpty() && + (state.chatHistory.isEmpty() || state.chatHistory.last().text != state.responseText) + ) { + ChatBubble(ChatTurn("assistant", state.responseText)) + } + } + } else { + val displayText = state.responseText.ifEmpty { + state.chatHistory.lastOrNull { it.role == "assistant" }?.text.orEmpty() + } + if (displayText.isNotEmpty()) { + Text( + text = displayText, + style = MaterialTheme.typography.bodyMedium, + color = TextPrimary, + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } } if (state.errorMessage != null) { Text( @@ -190,7 +220,16 @@ fun AssistantPillBar( // Bottom row: waveform + controls Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .then( + if (interruptable) { + Modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { onInterrupt() } + } else Modifier + ), verticalAlignment = Alignment.CenterVertically ) { // Mic icon @@ -248,6 +287,31 @@ fun AssistantPillBar( } } +private val UserBubbleBackground = Color(0xFF2563EB) +private val AssistantBubbleBackground = Color(0xFF1E1E3A) + +@Composable +private fun ChatBubble(turn: ChatTurn) { + val isUser = turn.role == "user" + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start + ) { + Text( + text = turn.text, + style = MaterialTheme.typography.bodySmall, + color = if (isUser) Color.White else TextPrimary, + modifier = Modifier + .widthIn(max = 260.dp) + .background( + color = if (isUser) UserBubbleBackground else AssistantBubbleBackground, + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) + } +} + @Composable private fun WaveformBar( phase: VoicePhase, diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeState.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeState.kt index 590e2592c..2b2aa34f9 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeState.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeState.kt @@ -2,6 +2,8 @@ package io.picoclaw.android.feature.chat.voice import io.picoclaw.android.core.domain.model.VoicePhase +data class ChatTurn(val role: String, val text: String) + data class VoiceModeState( val isActive: Boolean = false, val phase: VoicePhase = VoicePhase.IDLE, @@ -10,5 +12,6 @@ data class VoiceModeState( val statusText: String? = null, val errorMessage: String? = null, val amplitudeNormalized: Float = 0f, - val isCameraActive: Boolean = false + val isCameraActive: Boolean = false, + val chatHistory: List = emptyList() )