kaizen-app/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt
Bruno Deanoz 38678a2add refactor: extract hardcoded colors into KaizenSemanticColors
Single source of truth for error, success, indigo, link, and dropdown
colors. Replaces ~25 scattered Color(0xFF...) literals across 10 files.
Syntax highlighting and glass-surface tints intentionally left local.
2026-06-23 12:56:09 +02:00

1259 lines
66 KiB
Kotlin

package dev.kaizen.app.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import dev.kaizen.app.ui.icon.KaizenIcons
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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.platform.LocalConfiguration
import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.ui.effect.GlassSurface
import dev.kaizen.app.ui.effect.GlassTiers
import dev.kaizen.app.ui.effect.KaizenShadows
import dev.kaizen.app.ui.theme.KaizenSemanticColors
import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.R
import dev.kaizen.app.db.MessageEntity
import dev.kaizen.app.db.toEntity
import dev.kaizen.app.haptics.rememberHaptics
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ChatHttpException
import dev.kaizen.app.net.ChatSpeed
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.FetchResult
import dev.kaizen.app.net.KaizenApi
import dev.kaizen.app.net.KaizenModel
import dev.kaizen.app.net.SaveMessage
import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.net.WireMessage
import dev.kaizen.app.settings.SettingsScreen
import dev.kaizen.app.settings.SettingsViewModel
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.LifecycleResumeEffect
import android.Manifest
import android.content.pm.PackageManager
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.io.File
// Screen enumeration for modular state-driven navigation (Zero Technical Debt)
enum class AppScreen { Chat, Settings }
@Composable
fun ChatScreen(
settingsViewModel: SettingsViewModel,
session: SessionViewModel,
chat: ChatViewModel,
modifier: Modifier = Modifier
) {
val haptics = rememberHaptics()
val scope = rememberCoroutineScope()
val messages = remember { mutableStateListOf<Message>() }
val listState = rememberLazyListState()
var input by remember { mutableStateOf("") }
var isStreaming by remember { mutableStateOf(false) }
var nextId by remember { mutableStateOf(0L) }
var activeMode by remember { mutableStateOf<Int?>(null) }
var reasoningPreset by remember { mutableStateOf(ReasoningPreset.Standard) }
var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) }
var webSearchProvider by remember { mutableStateOf("google") }
var usedTokens by remember { mutableStateOf(0) }
var chatModel by remember { mutableStateOf<String?>(null) }
var streamJob by remember { mutableStateOf<Job?>(null) }
// Audio recording state
var isRecording by remember { mutableStateOf(false) }
val recorderRef = remember { mutableStateOf<MediaRecorder?>(null) }
val audioFileRef = remember { mutableStateOf<File?>(null) }
var recordingAmplitude by remember { mutableStateOf(0f) }
LaunchedEffect(isRecording) {
if (!isRecording) { recordingAmplitude = 0f; return@LaunchedEffect }
while (isRecording) {
val amp = try { recorderRef.value?.maxAmplitude ?: 0 } catch (_: Exception) { 0 }
recordingAmplitude = (amp / 32768f).coerceIn(0f, 1f)
kotlinx.coroutines.delay(60)
}
}
// TTS playback state
var playingTtsForId by remember { mutableStateOf<Long?>(null) }
val ttsPlayerRef = remember { mutableStateOf<MediaPlayer?>(null) }
var ttsVoice by remember { mutableStateOf("Kore") }
// Navigation State
var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
// Collect the user name reactively from the ViewModel
val userName by settingsViewModel.userName.collectAsState()
// Tablet & Landscape optimization: Detect large screens and cap the content width
val configuration = LocalConfiguration.current
val isTablet = configuration.screenWidthDp > 600
// Native sidebar drawer state
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
// Model picker: catalog (loads instantly from local cache!) + sheet visibility
var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) }
var showModelSheet by remember { mutableStateOf(false) }
// Conversation persistence: sidebar list from Room (instant), active conversation id
var conversationId by remember { mutableStateOf<String?>(null) }
var viewingLockedChat by remember { mutableStateOf(false) }
val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList())
var loadError by remember { mutableStateOf<String?>(null) }
// Password unlock dialog state — shown when biometrics unavailable for locked chats
var passwordUnlockTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var passwordUnlockError by remember { mutableStateOf<String?>(null) }
var passwordUnlockForToggle by remember { mutableStateOf(false) }
// Pending file attachments — uploaded immediately on pick, cleared on send.
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
val context = LocalContext.current
var showAttachMenu by remember { mutableStateOf(false) }
fun uploadPickedFile(name: String, mimeType: String, bytes: ByteArray) {
val cfg = session.config ?: return
val pf = PendingFile(name = name, mimeType = mimeType, bytes = bytes)
pendingFiles.add(pf)
scope.launch {
when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, mimeType, bytes)) {
is FetchResult.Ok -> {
val idx = pendingFiles.indexOfFirst { it.id == pf.id }
if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false)
}
is FetchResult.Fail -> {
val idx = pendingFiles.indexOfFirst { it.id == pf.id }
if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = r.reason, uploading = false)
}
}
}
}
val galleryPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
val cr = context.contentResolver
for (uri in uris) {
val mimeType = cr.getType(uri) ?: "image/jpeg"
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "image_${System.currentTimeMillis()}"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: continue
uploadPickedFile(fileName, mimeType, bytes)
}
}
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
val cr = context.contentResolver
for (uri in uris) {
val mimeType = cr.getType(uri) ?: "application/octet-stream"
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: continue
uploadPickedFile(fileName, mimeType, bytes)
}
}
val cameraPicker = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
bitmap ?: return@rememberLauncherForActivityResult
val stream = ByteArrayOutputStream()
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 92, stream)
uploadPickedFile("photo_${System.currentTimeMillis()}.jpg", "image/jpeg", stream.toByteArray())
}
val micPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) startRecording(context, recorderRef, audioFileRef) { isRecording = true }
else Toast.makeText(context, context.getString(R.string.chat_mic_permission), Toast.LENGTH_SHORT).show()
}
fun toggleRecording() {
if (isRecording) {
stopRecording(recorderRef, audioFileRef) { name, mime, bytes ->
isRecording = false
val cfg = session.config ?: return@stopRecording
scope.launch {
when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, mime, bytes)) {
is FetchResult.Ok -> {
val att = r.data
val userParentId = messages.lastOrNull()?.wireId
val userMsg = Message(nextId++, Role.User, "", attachments = listOf(att))
messages.add(userMsg)
val assistantId = nextId++
val assistantWireId = java.util.UUID.randomUUID().toString()
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
isStreaming = true
haptics.click()
val history = messages
.filter { it.id != assistantId }
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
val convIdDeferred = if (conversationId == null) {
scope.async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
} else null
streamJob = launch {
haptics.thinkingStart()
var sawContent = false
try {
KaizenApi.chat(
cfg.baseUrl, cfg.token, cfg.model, history,
attachments = listOf(att),
).collect { state ->
val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect
if (!sawContent && state.content.isNotEmpty()) {
sawContent = true; haptics.responseStart()
}
messages[idx] = messages[idx].copy(
thinking = if (sawContent) false else messages[idx].thinking,
content = state.content,
reasoning = state.reasoning,
tools = state.tools,
sources = state.sources,
query = state.query,
streaming = true,
)
}
} catch (_: kotlinx.coroutines.CancellationException) {
} catch (_: Exception) {}
val assistantIdx = messages.indexOfLast { it.id == assistantId }
if (assistantIdx >= 0) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
isStreaming = false
haptics.responseEnd()
// Persist
val cid = convIdDeferred?.await() ?: conversationId ?: return@launch
if (conversationId == null) conversationId = cid
val assistantMsg = messages.lastOrNull { it.id == assistantId } ?: return@launch
val finalContent = assistantMsg.content
val finalReasoning = assistantMsg.reasoning.takeIf { it.isNotBlank() }
val finalSources = assistantMsg.sources.takeIf { it.isNotEmpty() }
val userSave = SaveMessage(id = userMsg.wireId, parentId = userParentId, role = "user", content = "", attachments = listOf(att))
val assistantSave = SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model, reasoning = finalReasoning, sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) })
val saved = KaizenApi.saveMessages(cfg.baseUrl, cfg.token, cid, listOf(userSave, assistantSave))
if (saved) {
val msgCount = messages.size
chat.messageRepo.cacheMessages(cid, listOf(
dev.kaizen.app.db.MessageEntity(
id = userMsg.wireId, conversationId = cid,
role = "user", content = "",
attachments = listOf(att), sortOrder = msgCount - 2,
),
dev.kaizen.app.db.MessageEntity(
id = assistantWireId, conversationId = cid,
role = "assistant", content = finalContent,
attachments = emptyList(), sortOrder = msgCount - 1,
reasoning = finalReasoning,
sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) } ?: emptyList(),
),
))
}
if (convIdDeferred != null) {
chat.conversationRepo.insertOptimistic(cid, "(Voice)")
val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, cid)
if (title != null) chat.conversationRepo.updateTitle(cid, title)
chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
}
}
}
is FetchResult.Fail -> {
Toast.makeText(context, r.reason, Toast.LENGTH_SHORT).show()
}
}
}
}
} else {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
startRecording(context, recorderRef, audioFileRef) { isRecording = true }
} else {
micPermission.launch(Manifest.permission.RECORD_AUDIO)
}
}
}
fun playTtsUrl(url: String, messageId: Long) {
try {
val player = MediaPlayer().apply {
setDataSource(url)
prepareAsync()
setOnPreparedListener { start() }
setOnCompletionListener {
release()
ttsPlayerRef.value = null
playingTtsForId = null
}
setOnErrorListener { _, _, _ ->
release()
ttsPlayerRef.value = null
playingTtsForId = null
true
}
}
ttsPlayerRef.value = player
} catch (_: Exception) {
playingTtsForId = null
}
}
fun readAloud(content: String, messageId: Long, wireId: String) {
val cfg = session.config ?: return
if (playingTtsForId == messageId) {
ttsPlayerRef.value?.release()
ttsPlayerRef.value = null
playingTtsForId = null
return
}
ttsPlayerRef.value?.release()
playingTtsForId = messageId
val voice = ttsVoice
scope.launch {
val cached = chat.messageDao.getTtsUrl(wireId, voice)
if (cached != null) {
playTtsUrl(cached, messageId)
return@launch
}
val url = KaizenApi.generateSpeech(cfg.baseUrl, cfg.token, content, voice)
if (url == null) {
playingTtsForId = null
Toast.makeText(context, context.getString(R.string.chat_tts_error), Toast.LENGTH_SHORT).show()
return@launch
}
chat.messageDao.updateTts(wireId, url, voice)
playTtsUrl(url, messageId)
}
}
LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect
loadError = null
val errors = mutableListOf<String>()
when (val r = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)) {
is FetchResult.Ok -> {
models = r.data
if (r.data.isNotEmpty()) session.store.saveModelsCache(r.data)
}
is FetchResult.Fail -> {
if ("HTTP 401" in r.reason) { session.logout(); return@LaunchedEffect }
errors.add(r.reason)
}
}
KaizenApi.fetchMe(cfg.baseUrl, cfg.token)?.let { me ->
me.name?.let { settingsViewModel.updateUserName(it) }
me.email?.let { settingsViewModel.updateUserEmail(it) }
me.ttsVoice?.let { ttsVoice = it }
}
chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
if (errors.isNotEmpty()) {
loadError = errors.joinToString(" · ")
}
scope.launch {
val ids = chat.conversationRepo.allUnlockedIds()
chat.messageRepo.prefetchMissing(cfg.baseUrl, cfg.token, ids)
}
}
// Warm the HTTP/2 connection pool on every app resume (covers background → foreground
// after OkHttp's 5-minute keep-alive has expired). On first composition, the
// LaunchedEffect above already warms the pool via fetchModels/fetchConversations.
LifecycleResumeEffect(session.config?.baseUrl) {
session.config?.let { cfg ->
scope.launch { KaizenApi.prewarm(cfg.baseUrl) }
}
onPauseOrDispose {
if (viewingLockedChat) {
messages.clear()
viewingLockedChat = false
}
}
}
fun refreshConversations() {
val cfg = session.config ?: return
scope.launch { chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) }
}
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */
fun newChat() {
messages.clear()
conversationId = null
viewingLockedChat = false
usedTokens = 0
chatModel = null
}
fun unlockAndOpen(summary: ConversationSummary, password: String? = null) {
val cfg = session.config ?: return
scope.launch {
loadError = null
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password)
if (unlockToken == null) {
if (password != null) {
passwordUnlockError = context.getString(R.string.unlock_password_wrong)
} else {
loadError = context.getString(R.string.biometric_failed)
scope.launch { kotlinx.coroutines.delay(4000); if (loadError == context.getString(R.string.biometric_failed)) loadError = null }
}
return@launch
}
passwordUnlockTarget = null
passwordUnlockError = null
passwordUnlockForToggle = false
val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken)
if (stored.isNotEmpty()) {
messages.clear()
stored.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
),
)
}
conversationId = summary.id
viewingLockedChat = true
}
}
}
fun showPasswordFallback(summary: ConversationSummary, forToggle: Boolean = false) {
passwordUnlockTarget = summary
passwordUnlockError = null
passwordUnlockForToggle = forToggle
}
fun requestBiometricOrPassword(
summary: ConversationSummary,
forToggle: Boolean = false,
onBiometricSuccess: () -> Unit,
) {
val biometricEnabled = settingsViewModel.biometricEnabled.value
var activity: androidx.fragment.app.FragmentActivity? = null
var ctx = context
while (ctx is android.content.ContextWrapper) {
if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break }
ctx = ctx.baseContext
}
if (!biometricEnabled || activity == null || !dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) {
showPasswordFallback(summary, forToggle)
return
}
dev.kaizen.app.auth.BiometricUnlock.authenticate(
activity = activity,
title = context.getString(R.string.biometric_title),
subtitle = context.getString(R.string.biometric_subtitle),
negativeButtonText = context.getString(R.string.biometric_cancel),
) { result ->
when (result) {
is dev.kaizen.app.auth.BiometricUnlock.Result.Success -> onBiometricSuccess()
is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> showPasswordFallback(summary, forToggle)
is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled */ }
}
}
}
fun openConversation(summary: ConversationSummary) {
val cfg = session.config ?: return
if (summary.locked) {
requestBiometricOrPassword(summary) { unlockAndOpen(summary) }
return
}
viewingLockedChat = false
usedTokens = 0
loadError = null
scope.launch {
messages.clear()
val cachedList = chat.messageRepo.getCached(summary.id)
cachedList.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
),
)
}
conversationId = summary.id
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
if (stored.isNotEmpty()) {
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
chat.messageRepo.cacheMessages(summary.id, entities)
val lastAssistant = stored.lastOrNull { it.role == "assistant" }
lastAssistant?.model?.let { chatModel = it }
usedTokens = stored.sumOf { (it.inputTokens ?: 0) + (it.outputTokens ?: 0) }
if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) {
messages.clear()
stored.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
),
)
}
}
}
}
}
fun send(text: String) {
val trimmed = text.trim()
val uploadedAtts = pendingFiles.mapNotNull { it.uploaded }
if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return
if (isStreaming) return
val cfg = session.config ?: return
haptics.click()
val userParentId = messages.lastOrNull()?.wireId
val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts)
messages.add(userMsg)
input = ""
pendingFiles.clear()
val assistantId = nextId++
val assistantWireId = java.util.UUID.randomUUID().toString()
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
isStreaming = true
val wasNewConversation = conversationId == null
val history = messages
.filter { it.id != assistantId }
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
streamJob = scope.launch {
haptics.thinkingStart()
var sawContent = false
try {
val convIdDeferred = if (conversationId == null) {
async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
} else null
KaizenApi.chat(
cfg.baseUrl, cfg.token, cfg.model, history,
reasoningEffort = reasoningPreset.effort,
preferThroughput = reasoningPreset.throughput,
attachments = uploadedAtts.takeIf { it.isNotEmpty() },
webSearch = activeMode == R.string.mode_search,
webSearchProvider = if (activeMode == R.string.mode_search && cfg.model.startsWith("vertex:")) webSearchProvider else null,
temperature = samplingPrefs.temperature.takeIf { it.enabled }?.value,
topP = samplingPrefs.topP.takeIf { it.enabled }?.value,
topK = samplingPrefs.topK.takeIf { it.enabled }?.value?.toInt(),
).collect { state ->
val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect
if (!sawContent && state.content.isNotEmpty()) {
sawContent = true
haptics.responseStart()
}
messages[idx] = messages[idx].copy(
thinking = if (sawContent) false else messages[idx].thinking,
content = state.content,
reasoning = state.reasoning,
tools = state.tools,
query = state.query,
sources = state.sources,
)
if (state.inputTokens > 0 || state.outputTokens > 0) {
usedTokens = state.inputTokens + state.outputTokens
}
}
val idx = messages.indexOfLast { it.id == assistantId }
val finalMsg = if (idx >= 0) messages[idx] else null
val finalContent = finalMsg?.content ?: ""
val finalReasoning = finalMsg?.reasoning?.takeIf { it.isNotBlank() }
val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() }
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
val convId = convIdDeferred?.await()?.also { cid ->
conversationId = cid
scope.launch {
val preview = trimmed.take(60).lines().first()
chat.conversationRepo.insertOptimistic(cid, preview)
}
} ?: conversationId
if (convId != null && finalContent.isNotEmpty()) {
val saved = KaizenApi.saveMessages(
cfg.baseUrl, cfg.token, convId,
listOf(
SaveMessage(
id = userMsg.wireId, parentId = userParentId,
role = "user", content = trimmed,
attachments = uploadedAtts.takeIf { it.isNotEmpty() },
),
SaveMessage(
id = assistantWireId, parentId = userMsg.wireId,
role = "assistant", content = finalContent, model = cfg.model,
reasoning = finalReasoning,
sources = finalSources,
),
),
)
if (saved) {
val msgCount = messages.size
chat.messageRepo.cacheMessages(convId, listOf(
MessageEntity(
id = userMsg.wireId, conversationId = convId,
role = "user", content = trimmed,
attachments = uploadedAtts, sortOrder = msgCount - 2,
),
MessageEntity(
id = assistantWireId, conversationId = convId,
role = "assistant", content = finalContent,
attachments = emptyList(), sortOrder = msgCount - 1,
reasoning = finalReasoning,
sources = finalSources ?: emptyList(),
),
))
if (wasNewConversation) {
val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
if (title != null) {
chat.conversationRepo.updateTitle(convId, title)
}
}
refreshConversations()
}
}
} catch (e: Exception) {
val idx = messages.indexOfLast { it.id == assistantId }
if (e is kotlinx.coroutines.CancellationException) {
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
} else if (e is ChatHttpException && e.code == 401) {
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
session.logout()
} else if (idx >= 0) {
val existing = messages[idx].content
val errorSuffix = "\n\n${chatErrorText(e, context)}"
messages[idx] = messages[idx].copy(
streaming = false,
thinking = false,
content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e, context),
)
}
} finally {
isStreaming = false
streamJob = null
haptics.responseEnd()
}
}
}
// Auto-scroll: isolated from the main composition via derivedStateOf + snapshotFlow.
// Only the snapshot read triggers work — the root composable does NOT recompose on
// every streamed character, eliminating the recomposition storm that previously
// re-drew the entire screen (top bar, input, background) at 60+ fps.
val autoScrollKey by remember {
derivedStateOf { messages.size to (messages.lastOrNull()?.content?.length ?: 0) }
}
LaunchedEffect(Unit) {
snapshotFlow { autoScrollKey }.collect {
if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
}
}
// Single source of truth Screen routing
when (currentScreen) {
AppScreen.Settings -> {
SettingsScreen(
viewModel = settingsViewModel,
config = session.config,
onBack = { currentScreen = AppScreen.Chat },
modifier = modifier
)
}
AppScreen.Chat -> {
// ModalNavigationDrawer provides a native drawer that slides from left-to-right
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
KaizenSidebar(
userName = userName,
conversations = conversations,
currentId = conversationId,
onClose = { scope.launch { drawerState.close() } },
onNewChat = {
haptics.tick()
newChat()
scope.launch { drawerState.close() }
},
onSelectConversation = { summary ->
haptics.tick()
openConversation(summary)
scope.launch { drawerState.close() }
},
onOpenSettings = {
haptics.tick()
currentScreen = AppScreen.Settings
scope.launch { drawerState.close() }
},
onLogout = {
haptics.tick()
newChat()
scope.launch { drawerState.close() }
session.logout()
},
onAction = { action ->
val cfg = session.config ?: return@KaizenSidebar
haptics.tick()
scope.launch {
when (action) {
is SidebarAction.Rename -> {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("title" to action.newTitle))
refreshConversations()
}
is SidebarAction.Delete -> {
KaizenApi.deleteConversation(cfg.baseUrl, cfg.token, action.id)
if (conversationId == action.id) newChat()
refreshConversations()
}
is SidebarAction.TogglePin -> {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("pinned" to action.pinned))
refreshConversations()
}
is SidebarAction.ToggleLock -> {
if (action.locked) {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to true))
refreshConversations()
} else {
val summary = conversations.find { it.id == action.id }
if (summary != null) {
requestBiometricOrPassword(summary, forToggle = true) {
scope.launch {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to false))
refreshConversations()
}
}
}
}
}
is SidebarAction.GenerateTitle -> {
val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, action.id, manual = true)
if (title != null) {
chat.conversationRepo.updateTitle(action.id, title)
refreshConversations()
}
}
is SidebarAction.Duplicate -> {
val srcMessages = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, action.id)
if (srcMessages.isNotEmpty()) {
val srcTitle = conversations.find { it.id == action.id }?.title ?: ""
val newId = KaizenApi.createConversation(cfg.baseUrl, cfg.token, "$srcTitle (2)")
if (newId != null) {
val saveMessages = srcMessages.mapIndexed { i, m ->
dev.kaizen.app.net.SaveMessage(
id = java.util.UUID.randomUUID().toString(),
parentId = if (i == 0) null else null,
role = m.role,
content = m.content,
model = m.model,
attachments = m.attachments.takeIf { it.isNotEmpty() },
reasoning = m.reasoning,
sources = m.sources,
)
}
// Build parentId chain
val chained = saveMessages.mapIndexed { i, m ->
m.copy(parentId = if (i == 0) null else saveMessages[i - 1].id)
}
KaizenApi.saveMessages(cfg.baseUrl, cfg.token, newId, chained)
refreshConversations()
}
}
}
}
}
},
)
},
gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer
) {
MeshBackground(animateBlobs = messages.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// 1. Scrollable Chat Content
// Stretches to the full screen, allowing bubbles to scroll cleanly behind the floating bottom dock.
// Centered layout with responsive width capping on tablets.
if (messages.isEmpty()) {
EmptyHero(
userName = userName,
onPick = { haptics.tick(); send(it) },
modifier = Modifier
.fillMaxSize()
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
)
} else {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier),
contentPadding = PaddingValues(
top = 100.dp,
bottom = 260.dp,
start = 16.dp,
end = 16.dp
),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
items(messages, key = { it.id }) { message ->
Box(Modifier.animateItem(
fadeInSpec = tween(350),
placementSpec = spring(stiffness = Spring.StiffnessMediumLow),
fadeOutSpec = tween(200),
)) {
MessageRow(
message = message,
onDelete = if (conversationId != null && !isStreaming) { wireId ->
val cfg = session.config ?: return@MessageRow
messages.removeAll { it.wireId == wireId }
scope.launch {
KaizenApi.deleteMessage(cfg.baseUrl, cfg.token, conversationId!!, wireId)
}
} else null,
onRegenerate = if (message.role == Role.Assistant && !isStreaming) { _ ->
val lastUserIdx = messages.indexOfLast { it.role == Role.User && messages.indexOf(it) < messages.indexOf(message) }
if (lastUserIdx >= 0) {
val userContent = messages[lastUserIdx].content
messages.removeAt(messages.indexOf(message))
send(userContent)
}
} else null,
onEdit = if (message.role == Role.User && !isStreaming) { wireId, content ->
val idx = messages.indexOfFirst { it.wireId == wireId }
if (idx >= 0) {
val cfg = session.config ?: return@MessageRow
val toRemove = messages.subList(idx, messages.size).map { it.wireId }
messages.removeAll { it.wireId in toRemove }
if (conversationId != null) {
scope.launch {
toRemove.forEach { id ->
KaizenApi.deleteMessage(cfg.baseUrl, cfg.token, conversationId!!, id)
}
}
}
input = content
}
} else null,
onReadAloud = if (message.role == Role.Assistant && !message.streaming && message.content.isNotBlank()) { content ->
readAloud(content, message.id, message.wireId)
} else null,
isPlayingTts = playingTtsForId == message.id,
)
}
}
}
if (!isStreaming) {
val canScrollUp by remember { derivedStateOf { listState.canScrollBackward } }
val canScrollDown by remember { derivedStateOf { listState.canScrollForward } }
val showButton = canScrollUp || canScrollDown
val nearBottom by remember { derivedStateOf {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: 0
val total = info.totalItemsCount
if (total <= 0) true else lastVisible >= total - 2
}}
val pointsUp = !nearBottom && canScrollUp
if (showButton && !(canScrollUp && !canScrollDown && nearBottom)) {
GlassSurface(
shape = KaizenShapes.circle,
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
shadowStack = KaizenShadows.level1,
cornerRadius = 18.dp,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 8.dp)
.size(36.dp)
.clickable {
scope.launch {
if (pointsUp) listState.animateScrollToItem(0)
else listState.animateScrollToItem(messages.size - 1)
}
},
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
if (pointsUp) KaizenIcons.ArrowUp else KaizenIcons.ArrowDown,
null,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
// 2. Floating Top Menu Button
val scrimColor = MaterialTheme.colorScheme.background
Box(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0f to scrimColor,
0.7f to scrimColor.copy(alpha = 0.55f),
1f to Color.Transparent,
),
)
)
.statusBarsPadding()
) {
Row(
modifier = Modifier.padding(start = 14.dp, top = 10.dp, end = 14.dp, bottom = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val displayModel = chatModel ?: session.config?.model ?: ""
// Unified top bar: sidebar trigger + model name in one glass pill
GlassSurface(
shape = KaizenShapes.pill,
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
shadowStack = KaizenShadows.level1,
cornerRadius = 20.dp,
) {
Row(
Modifier.heightIn(min = 38.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Sidebar trigger area
Box(
Modifier
.size(38.dp)
.clip(KaizenShapes.circle)
.clickable { haptics.tick(); scope.launch { drawerState.open() } },
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = KaizenIcons.Menu,
contentDescription = context.getString(R.string.chat_open_menu),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(17.dp),
)
}
// Divider dot
Box(
Modifier
.size(3.dp)
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f), KaizenShapes.circle)
)
// Model name — tap opens picker
Box(
Modifier
.clip(KaizenShapes.pill)
.clickable { haptics.tick(); showModelSheet = true }
.padding(start = 10.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) {
Text(
modelDisplayName(displayModel, models),
color = MaterialTheme.colorScheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.W300,
maxLines = 1,
modifier = Modifier.widthIn(max = 180.dp),
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
}
}
}
Spacer(Modifier.weight(1f))
val ctxLen = models.find { it.id == displayModel }?.context_length ?: 1_000_000
TokenBadge(used = usedTokens, contextLength = ctxLen)
}
}
// Error banner when initial data load fails
if (loadError != null) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 62.dp, start = 24.dp, end = 24.dp)
.clip(KaizenShapes.sm)
.background(KaizenSemanticColors.errorDeep.copy(alpha = 0.15f))
.border(1.dp, KaizenSemanticColors.errorDeep.copy(alpha = 0.3f), KaizenShapes.sm)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
loadError!!,
color = KaizenSemanticColors.errorDeep,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
// 3. Floating Bottom Control Dock
@OptIn(ExperimentalLayoutApi::class)
val imeVisible = WindowInsets.isImeVisible
val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current)
val navBottom = WindowInsets.navigationBars.getBottom(LocalDensity.current)
val bottomOffset = if (imeVisible) maxOf(imeBottom, navBottom) else navBottom
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.offset(y = with(LocalDensity.current) { -bottomOffset.toDp() })
.background(
if (imeVisible) {
Brush.verticalGradient(
colorStops = arrayOf(
0f to scrimColor.copy(alpha = 0.5f),
1f to scrimColor.copy(alpha = 0.85f),
),
)
} else {
Brush.verticalGradient(
colorStops = arrayOf(
0f to Color.Transparent,
0.5f to scrimColor.copy(alpha = 0.25f),
1f to scrimColor.copy(alpha = 0.55f),
),
)
}
)
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
) {
Spacer(Modifier.height(if (imeVisible) 6.dp else 24.dp))
ModePillsRow(
activeMode = activeMode,
onToggle = { mode ->
activeMode = if (activeMode == mode) null else mode
haptics.tick()
},
reasoningPreset = reasoningPreset,
onReasoningChange = { reasoningPreset = it; haptics.tick() },
samplingPrefs = samplingPrefs,
onSamplingChange = { samplingPrefs = it },
currentModelId = session.config?.model ?: "",
webSearchProvider = webSearchProvider,
onWebSearchProviderChange = { webSearchProvider = it; haptics.tick() },
)
Spacer(Modifier.height(if (imeVisible) 6.dp else 10.dp))
ChatInput(
value = input,
onValueChange = { input = it },
onSend = { send(input) },
enabled = !isStreaming,
isStreaming = isStreaming,
onStop = { streamJob?.cancel() },
isRecording = isRecording,
recordingAmplitude = recordingAmplitude,
onMicClick = { toggleRecording() },
pendingFiles = pendingFiles,
onAddClick = { showAttachMenu = !showAttachMenu },
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
)
Spacer(Modifier.height(if (imeVisible) 4.dp else 16.dp))
}
// Dismiss scrim — tap anywhere to close the attachment menu
if (showAttachMenu) {
Box(
Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
indication = null,
) { showAttachMenu = false }
)
}
// Attachment menu — floats above the + button at bottom-left
androidx.compose.animation.AnimatedVisibility(
visible = showAttachMenu,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 6.dp, bottom = 140.dp),
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.slideInVertically { it / 2 },
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.slideOutVertically { it / 2 },
) {
AttachmentMenu(
onCamera = { showAttachMenu = false; cameraPicker.launch(null) },
onGallery = { showAttachMenu = false; galleryPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) },
onFile = { showAttachMenu = false; filePicker.launch(arrayOf("*/*")) },
)
}
// Model picker (bottom sheet), opened by the top pill
if (showModelSheet) {
ModelSheet(
models = models,
selectedModelId = session.config?.model ?: "",
favorites = session.favorites,
onSelectModel = { session.setModel(it); chatModel = it; haptics.tick() },
onToggleFavorite = { session.toggleFavorite(it) },
onDismiss = { showModelSheet = false },
)
}
// Password fallback dialog for locked conversations
val pwTarget = passwordUnlockTarget
if (pwTarget != null) {
dev.kaizen.app.auth.PasswordUnlockDialog(
onSubmit = { password ->
val cfg = session.config ?: return@PasswordUnlockDialog
if (passwordUnlockForToggle) {
scope.launch {
val token = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password)
if (token == null) {
passwordUnlockError = context.getString(R.string.unlock_password_wrong)
} else {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, pwTarget.id, mapOf("locked" to false))
refreshConversations()
passwordUnlockTarget = null
passwordUnlockError = null
passwordUnlockForToggle = false
}
}
} else {
unlockAndOpen(pwTarget, password)
}
},
onDismiss = {
passwordUnlockTarget = null
passwordUnlockError = null
passwordUnlockForToggle = false
},
error = passwordUnlockError,
)
}
}
}
}
}
}
}
private fun chatErrorText(e: Throwable, ctx: android.content.Context): String = when {
e is ChatHttpException && e.code == 401 -> ctx.getString(R.string.error_session_expired)
e is ChatHttpException && e.code == 402 -> ctx.getString(R.string.error_credits_exhausted)
e is ChatHttpException && e.code == 429 -> ctx.getString(R.string.error_rate_limited)
e is ChatHttpException -> ctx.getString(R.string.error_server, e.code)
else -> ctx.getString(R.string.error_no_connection)
}
@Suppress("DEPRECATION")
private fun startRecording(
context: android.content.Context,
recorderRef: androidx.compose.runtime.MutableState<MediaRecorder?>,
audioFileRef: androidx.compose.runtime.MutableState<File?>,
onStarted: () -> Unit,
) {
try {
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.m4a")
val recorder = MediaRecorder(context).apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioSamplingRate(44100)
setAudioEncodingBitRate(128000)
setOutputFile(file.absolutePath)
prepare()
start()
}
recorderRef.value = recorder
audioFileRef.value = file
onStarted()
} catch (_: Exception) {
recorderRef.value = null
audioFileRef.value = null
}
}
private fun stopRecording(
recorderRef: androidx.compose.runtime.MutableState<MediaRecorder?>,
audioFileRef: androidx.compose.runtime.MutableState<File?>,
onRecorded: (String, String, ByteArray) -> Unit,
) {
val recorder = recorderRef.value ?: return
val file = audioFileRef.value ?: return
try {
recorder.stop()
recorder.release()
} catch (_: Exception) {
recorder.release()
}
recorderRef.value = null
audioFileRef.value = null
if (file.exists() && file.length() > 0) {
val bytes = file.readBytes()
file.delete()
onRecorded("voice_${System.currentTimeMillis()}.m4a", "audio/mp4", bytes)
}
}