From 03e46c3d64120b3988114147f77b961706616428 Mon Sep 17 00:00:00 2001 From: Kohei Date: Sat, 21 Feb 2026 22:49:55 +0900 Subject: [PATCH] feat: add chat history to assistant overlay with tap-to-expand Text area tap now toggles scrollable chat bubble history view. Interrupt (cancel) is limited to the waveform row only. Chat history persists across interrupts and clears on session stop. Co-Authored-By: Claude Opus 4.6 --- .../chat/assistant/AssistantManager.kt | 11 +- .../chat/assistant/AssistantPillBar.kt | 102 ++++++++++++++---- .../feature/chat/voice/VoiceModeState.kt | 5 +- 3 files changed, 95 insertions(+), 23 deletions(-) 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() )