diff --git a/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt b/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt index 16e3bd4..3e70a82 100644 --- a/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt +++ b/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor @@ -49,6 +48,8 @@ import dev.kaizen.app.chat.MeshBackground import dev.kaizen.app.net.DEFAULT_BASE_URL import dev.kaizen.app.net.LoginResult import dev.kaizen.app.net.SessionViewModel +import dev.kaizen.app.ui.effect.KaizenShadows +import dev.kaizen.app.ui.effect.kaizenShadow import dev.kaizen.app.ui.theme.LocalKaizenAccent import androidx.compose.ui.res.stringResource import dev.kaizen.app.R @@ -181,7 +182,7 @@ private fun GlassField( Modifier .fillMaxWidth() .widthIn(max = 420.dp) - .shadow(elevation = 6.dp, shape = KaizenShapes.lg, clip = false) + .kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 20.dp) .clip(KaizenShapes.lg) .background(glassBg) .border(1.2.dp, glassBorder, KaizenShapes.lg) @@ -220,7 +221,7 @@ private fun SignInButton(enabled: Boolean, loading: Boolean, onClick: () -> Unit .fillMaxWidth() .widthIn(max = 420.dp) .heightIn(min = 54.dp) - .shadow(elevation = 8.dp, shape = KaizenShapes.lg, clip = false) + .kaizenShadow(stack = KaizenShadows.level2, cornerRadius = 20.dp) .clip(KaizenShapes.lg) .then(fill) .clickable(enabled = enabled, onClick = onClick) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index bc9d4c7..8a6699a 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -121,7 +121,7 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M val cs = MaterialTheme.colorScheme val now = remember { LocalDateTime.now() } val dateLine = remember { - now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN)) + now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.getDefault())) } Column( @@ -525,6 +525,8 @@ fun ChatInput( onValueChange: (String) -> Unit, onSend: () -> Unit, enabled: Boolean, + isStreaming: Boolean = false, + onStop: () -> Unit = {}, pendingFiles: List = emptyList(), onAddClick: () -> Unit = {}, onRemoveFile: (String) -> Unit = {}, @@ -590,7 +592,9 @@ fun ChatInput( Spacer(Modifier.weight(1f)) - if (hasContent) { + if (isStreaming) { + StopButton(onClick = onStop) + } else if (hasContent) { SendButton(enabled = canSend, onClick = onSend) } else { CallButton(onClick = { /* TODO: call feature */ }) @@ -702,6 +706,26 @@ private fun CallButton(onClick: () -> Unit) { } } +@Composable +private fun StopButton(onClick: () -> Unit) { + val isDark = isSystemInDarkTheme() + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val pressScale by animateFloatAsState(if (pressed) 0.88f else 1f, label = "stop") + + Box( + Modifier + .size(38.dp) + .scale(pressScale) + .clip(KaizenShapes.circle) + .background(Color(0xFFEF4444).copy(alpha = if (isDark) 0.85f else 0.80f)) + .clickable(interactionSource = interaction, indication = null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(KaizenIcons.Square, stringResource(R.string.chat_stop), tint = Color.White, modifier = Modifier.size(16.dp)) + } +} + // ── Attachment rendering ──────────────────────────────────────────────────── private object NetworkImageCache { diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 3eabaaa..cacc2fa 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -82,6 +82,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.LifecycleResumeEffect +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.launch import java.io.ByteArrayOutputStream @@ -108,6 +109,7 @@ fun ChatScreen( var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) } var usedTokens by remember { mutableStateOf(0) } var chatModel by remember { mutableStateOf(null) } + var streamJob by remember { mutableStateOf(null) } // Navigation State var currentScreen by remember { mutableStateOf(AppScreen.Chat) } @@ -199,7 +201,10 @@ fun ChatScreen( models = r.data if (r.data.isNotEmpty()) session.store.saveModelsCache(r.data) } - is FetchResult.Fail -> errors.add(r.reason) + 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) } @@ -395,7 +400,7 @@ fun ChatScreen( .filter { it.id != assistantId } .map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) } - scope.launch { + streamJob = scope.launch { haptics.thinkingStart() var sawContent = false try { @@ -490,7 +495,12 @@ fun ChatScreen( } } catch (e: Exception) { val idx = messages.indexOfLast { it.id == assistantId } - if (idx >= 0) { + 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( @@ -501,6 +511,7 @@ fun ChatScreen( } } finally { isStreaming = false + streamJob = null haptics.responseEnd() } } @@ -845,6 +856,8 @@ fun ChatScreen( onValueChange = { input = it }, onSend = { send(input) }, enabled = !isStreaming, + isStreaming = isStreaming, + onStop = { streamJob?.cancel() }, pendingFiles = pendingFiles, onAddClick = { showAttachMenu = !showAttachMenu }, onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } }, diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 1571799..fbcf14d 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -18,6 +18,7 @@ Remove Error Open menu + Stop response Take Photo Photo & Video Choose File diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb61786..cfb8fbf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ Entfernen Fehler Menü öffnen + Antwort stoppen Foto aufnehmen Foto & Video Datei auswählen