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.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<Unit> {
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<String>) {
_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<String>) {

View file

@ -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,18 +154,46 @@ 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()) {
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 = state.responseText,
text = displayText,
style = MaterialTheme.typography.bodyMedium,
color = TextPrimary,
maxLines = 6,
overflow = TextOverflow.Ellipsis
)
}
}
if (state.errorMessage != null) {
Text(
text = state.errorMessage,
@ -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,

View file

@ -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<ChatTurn> = emptyList()
)