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 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-21 22:49:55 +09:00
parent e21e2e90ef
commit 03e46c3d64
3 changed files with 95 additions and 23 deletions

View file

@ -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.SpeechRecognizerWrapper
import io.picoclaw.android.feature.chat.voice.SttResult import io.picoclaw.android.feature.chat.voice.SttResult
import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper 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 io.picoclaw.android.feature.chat.voice.VoiceModeState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -50,7 +51,7 @@ class AssistantManager(
if (loopJob?.isActive == true) return if (loopJob?.isActive == true) return
parentScope = scope parentScope = scope
_state.update { _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 { loopJob = scope.launch {
voiceLoop() voiceLoop()
@ -75,7 +76,7 @@ class AssistantManager(
loopJob = null loopJob = null
_state.update { _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() } loopJob = scope.launch { voiceLoop() }
} }
@ -123,7 +124,7 @@ class AssistantManager(
select<Unit> { select<Unit> {
userTextChannel.onReceive { text -> userTextChannel.onReceive { text ->
if (!text.isNullOrBlank()) { 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 { try {
val base64Images = if (_state.value.isCameraActive) { val base64Images = if (_state.value.isCameraActive) {
captureAndEncode() captureAndEncode()
@ -209,11 +210,15 @@ class AssistantManager(
private suspend fun speakAndDrain(firstContent: String, speechQueue: Channel<String>) { private suspend fun speakAndDrain(firstContent: String, speechQueue: Channel<String>) {
_state.update { it.copy(phase = VoicePhase.SPEAKING, responseText = firstContent) } _state.update { it.copy(phase = VoicePhase.SPEAKING, responseText = firstContent) }
ttsWrapper.speak(firstContent) ttsWrapper.speak(firstContent)
val chunks = mutableListOf(firstContent)
while (true) { while (true) {
val next = speechQueue.tryReceive().getOrNull() ?: break val next = speechQueue.tryReceive().getOrNull() ?: break
_state.update { it.copy(responseText = next) } _state.update { it.copy(responseText = next) }
ttsWrapper.speak(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<String>) { private suspend fun drainSpeechQueue(speechQueue: Channel<String>) {

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -34,7 +39,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.TextPrimary
import io.picoclaw.android.core.ui.theme.TextSecondary import io.picoclaw.android.core.ui.theme.TextSecondary
import io.picoclaw.android.feature.chat.voice.CameraCaptureManager 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 io.picoclaw.android.feature.chat.voice.VoiceModeState
import com.composables.icons.lucide.R as LucideR import com.composables.icons.lucide.R as LucideR
import kotlin.math.PI import kotlin.math.PI
@ -78,6 +86,8 @@ fun AssistantPillBar(
val interruptable = state.phase != VoicePhase.LISTENING && val interruptable = state.phase != VoicePhase.LISTENING &&
state.phase != VoicePhase.IDLE state.phase != VoicePhase.IDLE
var historyExpanded by remember { mutableStateOf(false) }
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@ -135,14 +145,6 @@ fun AssistantPillBar(
) )
) )
) )
.then(
if (interruptable) {
Modifier.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { onInterrupt() }
} else Modifier
)
.animateContentSize() .animateContentSize()
) { ) {
Column( Column(
@ -152,17 +154,45 @@ fun AssistantPillBar(
) { ) {
// Expanded content: response text area // Expanded content: response text area
AnimatedVisibility( 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)) { Column(
if (state.responseText.isNotEmpty()) { modifier = Modifier
Text( .padding(bottom = 12.dp)
text = state.responseText, .clickable(
style = MaterialTheme.typography.bodyMedium, indication = null,
color = TextPrimary, interactionSource = remember { MutableInteractionSource() }
maxLines = 6, ) { historyExpanded = !historyExpanded }
overflow = TextOverflow.Ellipsis ) {
) 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) { if (state.errorMessage != null) {
Text( Text(
@ -190,7 +220,16 @@ fun AssistantPillBar(
// Bottom row: waveform + controls // Bottom row: waveform + controls
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.then(
if (interruptable) {
Modifier.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { onInterrupt() }
} else Modifier
),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Mic icon // 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 @Composable
private fun WaveformBar( private fun WaveformBar(
phase: VoicePhase, phase: VoicePhase,

View file

@ -2,6 +2,8 @@ package io.picoclaw.android.feature.chat.voice
import io.picoclaw.android.core.domain.model.VoicePhase import io.picoclaw.android.core.domain.model.VoicePhase
data class ChatTurn(val role: String, val text: String)
data class VoiceModeState( data class VoiceModeState(
val isActive: Boolean = false, val isActive: Boolean = false,
val phase: VoicePhase = VoicePhase.IDLE, val phase: VoicePhase = VoicePhase.IDLE,
@ -10,5 +12,6 @@ data class VoiceModeState(
val statusText: String? = null, val statusText: String? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
val amplitudeNormalized: Float = 0f, val amplitudeNormalized: Float = 0f,
val isCameraActive: Boolean = false val isCameraActive: Boolean = false,
val chatHistory: List<ChatTurn> = emptyList()
) )