kaizen-app/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt
Bruno Deanoz ff66b80966 Wire Live Call end-to-end: turn persistence, model check, permission
Turn persistence: onTurnEnd handler creates conversation if needed,
adds user + assistant messages to UI list, caches in Room. Server
already persists via WS-Server — app only needs local state sync.

Model check: CallButton only triggers Live Call when a vertex:*live*
model is in the catalog (liveAvailable). Falls back to PTT otherwise.

Permission: RECORD_AUDIO runtime request via ActivityResultContracts
before starting the call. Denied → toast, no crash.

Title generation: endLiveCall triggers generateTitle + sidebar refresh,
same pattern as text chat.
2026-06-24 03:25:32 +02:00

1537 lines
80 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 android.content.ComponentName
import android.content.ServiceConnection
import android.os.IBinder
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.io.File
import dev.kaizen.app.live.CallStatus
import dev.kaizen.app.live.LiveCallOverlay
import dev.kaizen.app.live.LiveCallService
// 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) }
// Tree state for branch navigation
var chatTree by remember { mutableStateOf(ChatTree.EMPTY) }
val storedMessagesMap = remember { mutableMapOf<String, dev.kaizen.app.net.StoredMessage>() }
var editParentId by remember { mutableStateOf<String?>(null) }
fun rebuildVisibleMessages() {
val path = getVisiblePath(chatTree)
messages.clear()
for (entry in path) {
val sm = storedMessagesMap[entry.messageId] ?: continue
messages.add(
Message(
id = nextId++,
role = if (sm.role == "assistant") Role.Assistant else Role.User,
content = sm.content,
wireId = sm.id,
attachments = sm.attachments,
reasoning = sm.reasoning ?: "",
sources = sm.sources ?: emptyList(),
branchInfo = entry.branchInfo,
),
)
}
}
// 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) }
// ─── Live Call state (uses context/ttsVoice/currentConversationId declared below) ───
var liveService by remember { mutableStateOf<LiveCallService?>(null) }
var liveCallActive by remember { mutableStateOf(false) }
val liveStatus by liveService?.status?.collectAsState() ?: remember { mutableStateOf(CallStatus.IDLE) }
val liveSubtitle by liveService?.subtitle?.collectAsState() ?: remember { mutableStateOf("") }
val liveAmplitude by liveService?.amplitude?.collectAsState() ?: remember { mutableStateOf(0f) }
val liveUserSpeaking by liveService?.userSpeaking?.collectAsState() ?: remember { mutableStateOf(false) }
var liveStartedAt by remember { mutableStateOf(0L) }
var liveElapsedMs by remember { mutableStateOf(0L) }
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
// ─── Live Call service binding + control ──────────────────────────────
val liveServiceConnection = remember {
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
liveService = (binder as? LiveCallService.LocalBinder)?.service
}
override fun onServiceDisconnected(name: ComponentName?) {
liveService = null
}
}
}
LaunchedEffect(liveCallActive) {
if (liveCallActive) {
liveStartedAt = System.currentTimeMillis()
while (liveCallActive) {
liveElapsedMs = System.currentTimeMillis() - liveStartedAt
kotlinx.coroutines.delay(500)
}
}
}
val liveAvailable = models.any { it.id.startsWith("vertex:") && it.id.contains("live") }
fun startLiveCallInner() {
val cfg = session.config ?: return
liveCallActive = true
val intent = LiveCallService.intent(context)
context.startForegroundService(intent)
context.bindService(intent, liveServiceConnection, 0)
scope.launch {
var attempts = 0
while (liveService == null && attempts < 20) {
kotlinx.coroutines.delay(50)
attempts++
}
val svc = liveService ?: return@launch
// Create conversation if needed (same pattern as text chat)
val cid = conversationId ?: run {
val newId = KaizenApi.createConversation(cfg.baseUrl, cfg.token)
if (newId != null) {
conversationId = newId
chat.conversationRepo.insertOptimistic(newId, "Live Call")
}
newId
}
svc.onTurnEnd = { turn ->
scope.launch {
val convId = conversationId ?: return@launch
// Add messages to local UI
if (turn.userText.isNotEmpty()) {
val userMsg = Message(nextId++, Role.User, turn.userText, wireId = turn.userMessageId ?: java.util.UUID.randomUUID().toString())
messages.add(userMsg)
}
if (turn.assistantText.isNotEmpty()) {
val aMsg = Message(nextId++, Role.Assistant, turn.assistantText, wireId = turn.assistantMessageId ?: java.util.UUID.randomUUID().toString())
messages.add(aMsg)
chatModel = cfg.model
usedTokens = turn.usage.inputTokens + turn.usage.outputTokens
}
// Cache in Room (server already persisted via WS-Server)
val msgCount = messages.size
val entitiesToCache = mutableListOf<dev.kaizen.app.db.MessageEntity>()
if (turn.userMessageId != null && turn.userText.isNotEmpty()) {
entitiesToCache.add(dev.kaizen.app.db.MessageEntity(
id = turn.userMessageId, conversationId = convId,
role = "user", content = turn.userText,
attachments = emptyList(), sortOrder = msgCount - 2,
))
}
if (turn.assistantMessageId != null && turn.assistantText.isNotEmpty()) {
entitiesToCache.add(dev.kaizen.app.db.MessageEntity(
id = turn.assistantMessageId, conversationId = convId,
role = "assistant", content = turn.assistantText,
attachments = emptyList(), sortOrder = msgCount - 1,
))
}
if (entitiesToCache.isNotEmpty()) {
chat.messageRepo.cacheMessages(convId, entitiesToCache)
}
}
}
svc.onError = { code, message ->
scope.launch {
val errorText = message ?: code
Toast.makeText(context, errorText, Toast.LENGTH_SHORT).show()
}
}
svc.startCall(
baseUrl = cfg.baseUrl,
token = cfg.token,
model = cfg.model,
voice = ttsVoice,
conversationId = cid,
)
}
}
val liveCallPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) startLiveCallInner()
else Toast.makeText(context, context.getString(R.string.chat_mic_permission), Toast.LENGTH_SHORT).show()
}
fun startLiveCall() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
liveCallPermission.launch(Manifest.permission.RECORD_AUDIO)
return
}
startLiveCallInner()
}
fun endLiveCall() {
liveService?.onTurnEnd = null
liveService?.onError = null
liveService?.endCall()
liveCallActive = false
try { context.unbindService(liveServiceConnection) } catch (_: Exception) {}
liveService = null
// Generate title for new conversations
val cfg = session.config ?: return
val cid = conversationId ?: return
scope.launch {
val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, cid)
if (title != null) chat.conversationRepo.updateTitle(cid, title)
chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
}
}
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(),
),
))
storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage(
id = userMsg.wireId, role = "user", content = "",
parentId = userParentId, attachments = listOf(att),
)
storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage(
id = assistantWireId, role = "assistant", content = finalContent,
parentId = userMsg.wireId, model = cfg.model,
reasoning = finalReasoning,
sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) },
)
chatTree = buildChatTree(storedMessagesMap.values.toList(), chatTree.activeRootId, chatTree.activeChild)
}
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) {
scope.launch {
try {
val tmpFile = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
val req = okhttp3.Request.Builder().url(url).build()
KaizenApi.imageClient.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@withContext null
val bytes = resp.body!!.bytes()
java.io.File.createTempFile("tts_", ".wav", context.cacheDir).also { it.writeBytes(bytes) }
}
}
if (tmpFile == null) {
playingTtsForId = null
return@launch
}
val player = MediaPlayer().apply {
setOnPreparedListener { start() }
setOnCompletionListener {
release()
ttsPlayerRef.value = null
playingTtsForId = null
tmpFile.delete()
}
setOnErrorListener { _, _, _ ->
release()
ttsPlayerRef.value = null
playingTtsForId = null
tmpFile.delete()
true
}
setDataSource(tmpFile.absolutePath)
prepareAsync()
}
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 { session.checkServerVersion(cfg.baseUrl) }
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
chatTree = ChatTree.EMPTY
storedMessagesMap.clear()
}
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 data = KaizenApi.fetchLockedConversationData(cfg.baseUrl, cfg.token, summary.id, unlockToken)
if (data.messages.isNotEmpty()) {
storedMessagesMap.clear()
data.messages.forEach { storedMessagesMap[it.id] = it }
chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild)
rebuildVisibleMessages()
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 data = KaizenApi.fetchConversationData(cfg.baseUrl, cfg.token, summary.id)
if (data.messages.isNotEmpty()) {
val entities = data.messages.mapIndexed { i, m -> m.toEntity(summary.id, i) }
chat.messageRepo.cacheMessages(summary.id, entities)
val lastAssistant = data.messages.lastOrNull { it.role == "assistant" }
lastAssistant?.model?.let { chatModel = it }
usedTokens = data.messages.sumOf { (it.inputTokens ?: 0) + (it.outputTokens ?: 0) }
storedMessagesMap.clear()
data.messages.forEach { storedMessagesMap[it.id] = it }
chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild)
rebuildVisibleMessages()
}
}
}
fun send(text: String, branchParentId: String? = null, branchAttachments: List<Attachment> = emptyList()) {
val trimmed = text.trim()
val uploadedAtts = if (branchAttachments.isNotEmpty()) branchAttachments else pendingFiles.mapNotNull { it.uploaded }
if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return
if (isStreaming) return
val cfg = session.config ?: return
haptics.click()
val userParentId = editParentId ?: branchParentId ?: messages.lastOrNull()?.wireId
editParentId = null
val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts)
messages.add(userMsg)
input = ""
if (branchAttachments.isEmpty()) 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(),
),
))
// Update tree with the new nodes
storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage(
id = userMsg.wireId, role = "user", content = trimmed,
parentId = userParentId, attachments = uploadedAtts,
)
storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage(
id = assistantWireId, role = "assistant", content = finalContent,
parentId = userMsg.wireId, model = cfg.model,
reasoning = finalReasoning,
sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) },
)
chatTree = buildChatTree(
storedMessagesMap.values.toList(),
chatTree.activeRootId,
chatTree.activeChild,
)
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 = 140.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 userMsg = messages[lastUserIdx]
val parentId = chatTree.nodes[userMsg.wireId]?.parentId
val msgIdx = messages.indexOf(message)
if (msgIdx >= 0) {
messages.subList(msgIdx, messages.size).clear()
}
send(userMsg.content, branchParentId = parentId, branchAttachments = userMsg.attachments)
}
} else null,
onEdit = if (message.role == Role.User && !isStreaming) { wireId, content ->
val node = chatTree.nodes[wireId]
val parentId = node?.parentId
val idx = messages.indexOfFirst { it.wireId == wireId }
if (idx >= 0) {
messages.subList(idx, messages.size).clear()
editParentId = parentId
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,
branchInfo = message.branchInfo,
onNavigateBranch = if (!isStreaming) { wireId, direction ->
val newTree = treeNavigate(chatTree, wireId, direction)
chatTree = newTree
rebuildVisibleMessages()
if (conversationId != null) {
val cfg = session.config ?: return@MessageRow
scope.launch {
KaizenApi.patchConversation(
cfg.baseUrl, cfg.token, conversationId!!,
mapOf(
"activeRootId" to newTree.activeRootId,
"activeChild" to newTree.activeChild,
),
)
}
}
} else null,
)
}
}
}
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))
Box(
Modifier.size(36.dp).clip(KaizenShapes.circle)
.clickable { haptics.tick(); newChat() },
contentAlignment = Alignment.Center,
) {
Icon(
KaizenIcons.PenLine, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(17.dp),
)
}
Spacer(Modifier.width(4.dp))
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,
)
}
}
// Version skew warning banner
val skew = session.versionSkew
if (skew != null) {
val warningColor = Color(0xFFD97706)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = if (loadError != null) 100.dp else 62.dp, start = 24.dp, end = 24.dp)
.clip(KaizenShapes.sm)
.background(warningColor.copy(alpha = 0.15f))
.border(1.dp, warningColor.copy(alpha = 0.3f), KaizenShapes.sm)
.clickable { session.dismissVersionWarning() }
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
stringResource(R.string.error_version_skew, skew.serverVersion, skew.expectedVersion),
color = warningColor,
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) 4.dp else 12.dp))
ChatInput(
value = input,
onValueChange = { input = it },
onSend = { send(input) },
enabled = !isStreaming,
isStreaming = isStreaming,
onStop = { streamJob?.cancel() },
isRecording = isRecording,
recordingAmplitude = recordingAmplitude,
onMicClick = { toggleRecording() },
onCallClick = if (liveAvailable) {{ startLiveCall() }} else {{ /* PTT TODO */ }},
pendingFiles = pendingFiles,
onAddClick = { showAttachMenu = !showAttachMenu },
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
pillsContent = {
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) 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,
)
}
}
}
}
// ─── Live Call Overlay ────────────────────────────────
androidx.compose.animation.AnimatedVisibility(
visible = liveCallActive,
enter = androidx.compose.animation.fadeIn(tween(250)),
exit = androidx.compose.animation.fadeOut(tween(200)),
) {
LiveCallOverlay(
status = liveStatus,
subtitle = liveSubtitle,
amplitude = liveAmplitude,
userSpeaking = liveUserSpeaking,
elapsedMs = liveElapsedMs,
onEndCall = { endLiveCall() },
onInterrupt = { liveService?.interrupt() },
)
}
}
}
}
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)
}
}