Compare commits

..

15 commits

Author SHA1 Message Date
Bruno Deanoz
f22838e99a message action buttons: copy, delete, regenerate
Under each message (when not streaming), small action icons:

User messages: Copy (clipboard → green check) + Delete (red trash).
Delete calls DELETE /api/v1/conversations/[id]/messages and removes
from the in-memory list optimistically.

Assistant messages: Copy + Regenerate (refresh icon, resends the
preceding user message) + Delete. Regenerate removes the assistant
message and re-calls send() with the original user text.

30dp circular tap targets, 16dp icons, subtle 0.35 alpha — visible
but not distracting, matching the web's action button style.
2026-06-22 00:45:18 +02:00
Bruno Deanoz
4ebe812c42 fix themes barely visible: add blob3, increase alpha, soften vignette
Three issues causing themes to look nearly identical:

1. blob3 was never rendered — each theme defines 4 blob colors but
   MeshBackground only used blob1/blob2/blob4. Added blob3 (honey
   for Amber, indigo for Blau, etc.) as a 4th blob.

2. Blob alpha too low — dark 0.22-0.30, light ×0.55 made blobs
   almost invisible. Increased to 0.30-0.42 dark, ×0.65 light.
   Now Amber clearly shows warm golds/corals, Blau shows cool
   sky/cyan/indigo — themes are visually distinct.

3. Vignette too aggressive — 0.55 alpha covered blob edges.
   Reduced to 0.40 so theme colors reach further into corners.
2026-06-22 00:38:07 +02:00
Bruno Deanoz
f7849025d7 rewrite orb from opaque painted ball to transparent glass lens
The web orb looks like glass because backdrop-filter refracts the
background through it. Android has no backdrop-filter, so the old
approach painted an opaque amber ball with harsh colors (#FFF1D6,
#8D4F00 at 85% alpha) that looked like a sticker, not glass.

New approach: very low alpha accent tint (15-22%) so background
blobs bleed through, creating a lens effect. Light source at
top-left with subtle white radial, warm accent bleed bottom-right,
gentle bottom shadow for volume (not 85% dark brown). Elliptical
specular highlight instead of circular. Double rim (outer hairline
+ inner inset) matching the web's box-shadow approach. Caustic
sweep reduced to 25-30% alpha (was 50-65%).
2026-06-22 00:35:21 +02:00
Bruno Deanoz
2d57afff61 input field: more breathing room, mic/send toggle
Reduced outer padding (12dp→8dp) for wider feel, increased inner
padding (8dp→10dp) and min height (36dp→42dp) for more air.
Placeholder text slightly smaller (15sp) and more subtle (0.6α).
Text line-height increased to 24sp.

Single action button: shows Mic icon when input is empty, morphs
to Send button when text is entered — like WhatsApp/iMessage.
Removed separate Call button that took space without function.
Aligned action buttons to bottom for multi-line input.
2026-06-22 00:31:33 +02:00
Bruno Deanoz
fb90577884 use model context_length for token badge instead of hardcoded 1M
Parse context_length from /api/v1/models response (KaizenModel)
and look up the active model's value for the token counter badge.
Falls back to 1M only if the model has no context_length field.
2026-06-22 00:28:16 +02:00
Bruno Deanoz
3ca1d2c18e scroll buttons, token counter, 3D user bubble
Scroll: glass arrow buttons at center-right edge, appear when
scrolled away from top/bottom. Animate scroll on tap.

Token counter: parse usage sentinel (input+output tokens) from
stream, show " Xk / 1M" badge next to model pill in top bar.
StreamState extended with inputTokens/outputTokens.

User bubble: multi-layer 3D effect — higher opacity gradient bg,
inner top highlight (accent glow), kaizenShadow level1 for depth,
thicker border gradient. Increased padding and line-height (26sp)
for better readability.
2026-06-22 00:26:31 +02:00
Bruno Deanoz
f6dc5db363 reasoning presets + sampling controls (functional, not mock)
Reasoning pill (Brain icon + dropdown): 8 presets matching web
(Sofort/Aus/Minimal/Niedrig/Standard/Mittel/Hoch/Maximal).
"Standard" = no override, "Sofort" = no reasoning + throughput.
Sends reasoningEffort + preferThroughput in ChatRequest.

Sampling pill (Tune icon + popover): per-parameter toggle +
slider for Temperature (0-2), Top-P (0-1), Top-K (1-100).
Each parameter independently on/off — only enabled ones are
sent. Accent highlight when any param is enabled.

Both controls are separate from mode pills (Bild/Suche/Video)
and work in chat + search modes, matching the web layout.

ChatRequest extended with temperature/topP/topK fields.
Old ChatSpeed enum no longer used for reasoning (kept for
backward compat with ServerConfig).
2026-06-22 00:19:14 +02:00
Bruno Deanoz
30d3f2807d wire search mode, single-select pills, remove Standard pill
Mode pills are now single-select (tap to activate, tap again to
deactivate) matching the web frontend behavior. Removed the
"Standard" pill — it's the implicit default when no mode is active.

Search mode sends webSearch: true in the ChatRequest body, which
triggers the backend's agentive web search (openrouter:web_search
or Google Search grounding). Sources and query are already parsed
by StreamConsumer and rendered via SourcesBlock/SourceChip.

Active pills now highlighted with accent color + accent border
instead of generic surface alpha, making the active state
visually obvious.
2026-06-22 00:10:14 +02:00
Bruno Deanoz
a16dfffd5c auto-lock on background + reasoning/sources tests
Clear locked chat messages when app goes to background
(onPauseOrDispose). Track viewingLockedChat state — reset on
new chat or opening a non-locked conversation.

Add tests: reasoning round-trip, null reasoning preserved,
sources round-trip, empty sources → null, reasoning+sources
together, SearchSource converter (empty/single/multi/malformed/
unknown fields).
2026-06-22 00:03:59 +02:00
Bruno Deanoz
edbdbd8697 docs: update CLAUDE.md with biometric unlock + reasoning persistence 2026-06-21 23:58:05 +02:00
Bruno Deanoz
88559bf49e biometric unlock for locked conversations
Hardware-enforced via Android KeyStore + CryptoObject:
- AES key in TEE/StrongBox with setUserAuthenticationRequired(true)
- BiometricPrompt receives Cipher as CryptoObject, unlocked only
  after real biometric auth (fingerprint/face), not bypassable
- Key auto-invalidated on biometric enrollment change

Flow: tap locked chat → BiometricPrompt → on success request
server unlock token (POST /api/v1/auth/unlock) → fetch messages
with X-Unlock-Token header → display conversation.

Dependencies: androidx.biometric:biometric:1.4.0-alpha02
2026-06-21 23:56:57 +02:00
Bruno Deanoz
278d98d125 fix input field clipped by keyboard
Add imePadding() to the bottom dock so it pushes above the soft
keyboard. Previously only navigationBarsPadding() was set, which
handles the gesture bar but not the IME — the input field was
partially hidden behind the keyboard suggestion row.
2026-06-21 23:43:13 +02:00
Bruno Deanoz
532cb8c27b persist reasoning + sources, render reasoning above content
Fix two bugs:
1. ReasoningBlock rendered below content — moved above, matching
   web frontend (reasoning first, then content, sources at bottom)
2. Reasoning and sources lost after closing chat — now persisted
   end-to-end: sent to server via SaveMessage, loaded back via
   StoredMessage, cached in Room via MessageEntity + SearchSource
   TypeConverter. Syncs correctly between web and app.

Room schema change triggers destructive migration (cache rebuild).
2026-06-21 23:39:07 +02:00
Bruno Deanoz
f3d8154052 sidebar redesign + markdown typography overhaul
Sidebar: increase glass opacity (0.92/0.88), flat conversation rows
instead of pill shapes, 3-dot context menu (rename/delete/pin/lock),
smaller profile avatar (30dp), logout integrated into user card.
Add KaizenApi.patchConversation + deleteConversation for CRUD.

Markdown: increase line-height from 23sp to 26sp (×1.625 ratio),
double paragraph spacing (6dp→12dp), generous heading margins
(18dp top, 8dp bottom), wider list item spacing — matches Gemini-
level readability.
2026-06-21 23:31:03 +02:00
Bruno Deanoz
3986443a56 docs: update CLAUDE.md with final attachment menu design 2026-06-21 20:06:43 +02:00
21 changed files with 1430 additions and 276 deletions

View file

@ -35,7 +35,7 @@ Make the **native Android app** (`kaizen-app`) a valid daily-driver that talks t
### Package structure (`app/src/main/java/dev/kaizen/app/`)
```
auth/ LoginScreen
auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject)
chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels,
ModelSheet, Markdown, Background
db/ Room offline cache: KaizenDatabase, Entities, DAOs,
@ -174,7 +174,7 @@ Send message → in-memory during streaming → Room flush after stream ends
- `db/KaizenDatabase.kt` — singleton via `companion object` (double-checked locking), thread-safe
- `db/ConversationRepository.kt``observeAll()` (Flow of ConversationSummary), `refresh()` (server → Room), `allUnlockedIds()`
- `db/MessageRepository.kt``observeMessages()`, `getCached()`, `fetchAndCache()`, `cacheMessages()`, `prefetchMissing()` (background prefetch for offline)
- `db/Converters.kt``List<Attachment>` ↔ JSON string via kotlinx.serialization
- `db/Converters.kt``List<Attachment>` + `List<SearchSource>` ↔ JSON string via kotlinx.serialization
- `db/Mappers.kt``ConversationSummary``ConversationEntity`, `StoredMessage``MessageEntity`
**AGP 9.x compat:** `android.disallowKotlinSourceSets=false` in `gradle.properties` (KSP issue [#2729](https://github.com/google/ksp/issues/2729)).
@ -191,6 +191,8 @@ All backend work for the app is done. `pnpm test:run` green (774+), `tsc` + `lin
- **`GET /api/v1/me`** — identity + `defaultModel` for native clients.
- **`GET /api/v1/models`** — Bearer-authed, returns filtered model catalog.
- **`/api/v1/conversations`** — thin re-exports of canonical handlers, Bearer-authed.
- **`POST /api/v1/auth/unlock`** — Bearer-authed, returns unlock token in response body (not cookie) for native clients. Rate-limited 10/5min per user.
- **`GET /api/v1/conversations/[id]`** — accepts `X-Unlock-Token` header alongside the existing `kaizen_unlock` cookie for locked conversations.
- **`GET /api/v1/meta`** — capability handshake.
- **App-devices card** (`components/settings/app-tokens-card.tsx`) — see/revoke signed-in devices on `/settings/security`.
- **API versioning**`/api/v1/` for outward contract, additive-only within v1. Web-internal endpoints stay unversioned.
@ -221,7 +223,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
| `d468ec8` | **Theme switching wired** — all 7 files migrated from hardcoded Amber to `LocalKaizenAccent.current` |
| `cad445f` | **i18n cleanup** — replaced all remaining hardcoded German strings with `stringResource()` |
| `5c54b9b` | **ToolEventsBlock rewrite** — state machine matching web frontend (thinking/analyzing/start/end/error) |
| `0948931` | **Attachment picker menu** — camera capture, gallery (image/*), and file picker (*/*) via dropdown |
| `da01b57` | **Attachment picker menu** — three floating GlassSurface pills (camera/gallery/file) with colored round icons, slide animation, dismiss-on-tap-outside. Positioned bottom-left above the + button. Camera uses `TakePicturePreview`, gallery filters `image/*`, file allows `*/*` |
**Earlier UI/feel work (Phase 0 prototype → feel-first):**
- Liquid glass styling, floating panels, glassmorphic sidebar
@ -254,13 +256,13 @@ These are what separate "prototype" from "daily-driver":
- [ ] **Conversation rename/delete** from the sidebar (currently read-only)
- [ ] **Conversation pin/unpin** from the sidebar
- [ ] **In-app unlock for locked conversations** (currently skipped with "Gesperrter Chat" label)
- [x] **In-app unlock for locked conversations** — BiometricPrompt with CryptoObject (AES key in TEE/StrongBox), hardware-enforced biometric auth → server unlock token via `POST /api/v1/auth/unlock` → fetch messages with `X-Unlock-Token` header
### 3. Security hardening
- [ ] **Auto-logout on 401** — if the token is rejected, route to login screen instead of showing error banner
- [ ] **Server version check** — call `/api/v1/meta` after login to detect server skew and warn cleanly
- [ ] **Biometric lock** (`BiometricPrompt` + KeyStore binding) — see `LATER.md` §1C
- [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — implemented for conversation unlock; `setUserAuthenticationRequired(true)` + `setInvalidatedByBiometricEnrollment(true)` ensures hardware-enforced biometric, not bypassable even on rooted devices
- [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B
### 4. Features for parity with the web

View file

@ -60,6 +60,8 @@ dependencies {
// Room offline cache (local SQLite read-cache for conversations + messages)
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
// Biometric auth (fingerprint/face unlock) for locked conversations — hardware-enforced via TEE/StrongBox
implementation(libs.androidx.biometric)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -0,0 +1,158 @@
package dev.kaizen.app.auth
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.util.Log
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
private const val TAG = "BiometricUnlock"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val KEY_ALIAS = "kaizen_biometric_unlock"
/**
* Hardware-enforced biometric unlock using Android KeyStore + CryptoObject.
*
* A symmetric AES key is generated in the TEE/StrongBox with
* [KeyGenParameterSpec.Builder.setUserAuthenticationRequired] the key
* material never leaves the secure hardware and can only be used after
* successful biometric authentication. [BiometricPrompt] receives a
* [Cipher] backed by this key as its [BiometricPrompt.CryptoObject];
* the Cipher is unlocked by the TEE only after the biometric check
* passes. Even on rooted devices the key cannot be extracted or used
* without a real biometric event.
*
* If biometric enrollment changes (new fingerprint added), the key is
* invalidated ([setInvalidatedByBiometricEnrollment]) and automatically
* re-generated on the next attempt.
*/
object BiometricUnlock {
sealed interface Result {
data object Success : Result
data object NotAvailable : Result
data class Failed(val message: String) : Result
}
fun isAvailable(activity: FragmentActivity): Boolean {
val bm = BiometricManager.from(activity)
return bm.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(
activity: FragmentActivity,
title: String,
subtitle: String,
negativeButtonText: String,
onResult: (Result) -> Unit,
) {
if (!isAvailable(activity)) {
onResult(Result.NotAvailable)
return
}
val cipher = try {
getCipher()
} catch (e: KeyPermanentlyInvalidatedException) {
Log.w(TAG, "Key invalidated (biometric enrollment changed), regenerating")
deleteKey()
try {
getCipher()
} catch (e2: Exception) {
Log.e(TAG, "Failed to regenerate key", e2)
onResult(Result.Failed(e2.message ?: "KeyStore error"))
return
}
} catch (e: Exception) {
Log.e(TAG, "Failed to init cipher", e)
onResult(Result.Failed(e.message ?: "KeyStore error"))
return
}
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val authedCipher = result.cryptoObject?.cipher
if (authedCipher != null) {
onResult(Result.Success)
} else {
onResult(Result.Failed("CryptoObject missing after auth"))
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
errorCode == BiometricPrompt.ERROR_CANCELED
) {
onResult(Result.Failed("cancelled"))
} else {
onResult(Result.Failed(errString.toString()))
}
}
override fun onAuthenticationFailed() {
// Called on each failed attempt (wrong finger), prompt stays open.
// Only onAuthenticationError terminates the flow.
}
})
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setNegativeButtonText(negativeButtonText)
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
prompt.authenticate(promptInfo, cryptoObject)
}
private fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
}
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
.apply { init(builder.build()) }
.generateKey()
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
}
private fun getCipher(): Cipher {
val key = getOrCreateKey()
return Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}")
.apply { init(Cipher.ENCRYPT_MODE, key) }
}
private fun deleteKey() {
try {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
keyStore.deleteEntry(KEY_ALIAS)
} catch (_: Exception) { }
}
}

View file

@ -46,7 +46,7 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
val accent = LocalKaizenAccent.current
val base = MaterialTheme.colorScheme.background
// Blobs are subtler in light mode.
val factor = if (dark) 1f else 0.55f
val factor = if (dark) 1f else 0.65f
val transition = rememberInfiniteTransition(label = "blobs")
val drift by transition.animateFloat(
initialValue = 0f,
@ -65,14 +65,14 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
drawRect(brush = ditherBrush, alpha = DITHER_ALPHA)
},
) {
Blob(accent.blob1, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f * factor)
Blob(accent.blob2, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f * factor)
Blob(accent.blob4, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor)
// Vignette: darken the edges so the center comes forward (dark mode only).
Blob(accent.blob1, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.42f * factor)
Blob(accent.blob2, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.36f * factor)
Blob(accent.blob3, 320.dp, (220f + drift * 36f).dp, (80f - drift * 28f).dp, 0.30f * factor)
Blob(accent.blob4, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.34f * factor)
if (dark) {
Box(
Modifier.fillMaxSize().background(
Brush.radialGradient(listOf(Color.Transparent, base.copy(alpha = 0.55f))),
Brush.radialGradient(listOf(Color.Transparent, base.copy(alpha = 0.40f))),
),
)
}
@ -137,8 +137,7 @@ fun KaizenOrb(
.pointerInput(Unit) {},
contentAlignment = Alignment.Center
) {
// --- LAYER 1: Ambient Outer Halo (Subtle larger glow) ---
// This is the ONLY layer that is blurred, providing a soft background glow.
// --- LAYER 1: Ambient Outer Halo ---
Box(
Modifier
.size(size * 1.5f)
@ -147,137 +146,143 @@ fun KaizenOrb(
.background(
Brush.radialGradient(
colors = if (recording) {
listOf(Color(0xFFE0A23E).copy(alpha = 0.45f), Color.Transparent)
listOf(accent.primary.copy(alpha = 0.45f), Color.Transparent)
} else {
listOf(accent.primary.copy(alpha = if (streaming) 0.55f else 0.32f), Color.Transparent)
listOf(accent.primary.copy(alpha = if (streaming) 0.50f else 0.28f), Color.Transparent)
}
),
CircleShape,
),
)
// --- LAYER 2: Glass Sphere body with refracted backgrounds & conic caustics ---
// DRAWN PERFECTLY SHARP (No blur modifier on the Canvas!) to ensure razor-sharp,
// solid circular boundaries that give the orb its heavy, crystal-glass volume.
Canvas(
modifier = Modifier.fillMaxSize()
) {
val w = size.toPx()
val h = size.toPx()
val radius = w / 2f
// Shift centers slightly in direction of gravity to simulate holographic refractive parallax
val centerShiftX = -tiltX * 0.12f * w
val centerShiftY = tiltY * 0.12f * h
// 1. Base Gradient Fill (Refraction glow matching active theme)
val baseGradientCenter = Offset(w * 0.35f + centerShiftX, h * 0.30f + centerShiftY)
drawCircle(
brush = Brush.radialGradient(
colorStops = arrayOf(
0.0f to Color(0xFFFFF1D6), // Ultra bright glowing cream-white core
0.35f to Color(0xFFFFCC80), // Rich luminous warm amber
0.75f to accent.primaryDeep, // Saturated accent
1.0f to Color(0xFF8D4F00) // Deep bronze-amber shadow edge (creates high contrast!)
),
center = baseGradientCenter,
radius = radius * 1.1f
)
)
// 2. Heavy 3D Inner Volume Shadow (Sphere Bottom Shading)
// Adds massive physical 3D weight by darkening the sphere's lower edge.
drawCircle(
brush = Brush.radialGradient(
colorStops = arrayOf(
0.0f to Color.Transparent,
0.5f to Color.Transparent,
1.0f to Color(0xFF4A2300).copy(alpha = 0.85f) // Deep dark brown shadow edge
),
center = Offset(w * 0.35f + centerShiftX, h * 0.30f + centerShiftY),
radius = radius * 1.0f
)
)
// Shift inner core in the opposite direction (parallax depth)
val coreShiftX = tiltX * 0.05f * w
val coreShiftY = -tiltY * 0.05f * h
val coreCenter = Offset(w / 2f + coreShiftX, h / 2f + coreShiftY)
// 3. Conic Caustic Layer #1 (SweepGradient at 30° blended via BlendMode.Overlay)
val sweepBrush1 = Brush.sweepGradient(
colorStops = arrayOf(
0.0f to Color.Transparent,
0.16f to accent.primary.copy(alpha = 0.65f),
0.33f to Color.White.copy(alpha = 0.55f),
0.50f to Color.Transparent,
0.66f to accent.primaryDeep.copy(alpha = 0.50f),
1.0f to Color.Transparent
),
center = coreCenter
)
drawCircle(
brush = sweepBrush1,
radius = radius * 0.84f,
blendMode = BlendMode.Overlay
)
// 4. Conic Caustic Layer #2 (Secondary parallax SweepGradient at 200° via BlendMode.Softlight)
val sweepBrush2 = Brush.sweepGradient(
colorStops = arrayOf(
0.0f to Color.Transparent,
0.25f to Color.White.copy(alpha = 0.40f),
0.50f to Color.Transparent,
0.75f to accent.primary.copy(alpha = 0.50f),
1.0f to Color.Transparent
),
center = coreCenter
)
drawCircle(
brush = sweepBrush2,
radius = radius * 0.72f,
blendMode = BlendMode.Softlight
)
}
// --- LAYER 3: Specular Highlights & Crisp Rim (Sharp reflections) ---
// --- LAYER 2: Glass body — transparent tint so background bleeds through ---
Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.toPx()
val h = size.toPx()
val radius = w / 2f
val shiftX = -tiltX * 0.12f * w
val shiftY = tiltY * 0.12f * h
val lightCenter = Offset(w * 0.30f + shiftX, h * 0.25f + shiftY)
// Specular shifts with gravity to give a high-gloss interactive reflection
val specularShiftX = -tiltX * 0.08f * w
val specularShiftY = tiltY * 0.08f * h
val specularCenter1 = Offset(w * 0.28f + specularShiftX, h * 0.22f + specularShiftY)
val specularCenter2 = Offset(w * 0.70f + specularShiftX, h * 0.82f + specularShiftY)
// 1. Primary "wet" gloss highlight (top-left) - extremely bright, solid, and sharp!
// Base: very subtle theme tint (glass, not paint)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color.White, Color.Transparent),
center = specularCenter1,
radius = radius * 0.38f
)
colorStops = arrayOf(
0.0f to accent.primary.copy(alpha = if (isDark) 0.22f else 0.15f),
0.6f to accent.primaryDeep.copy(alpha = if (isDark) 0.18f else 0.10f),
1.0f to accent.primaryDeep.copy(alpha = if (isDark) 0.30f else 0.20f),
),
center = lightCenter,
radius = radius * 1.1f,
),
)
// 2. Secondary ambient bounce highlight (bottom-right edge glow)
// Top-left brightness (light source)
drawCircle(
brush = Brush.radialGradient(
colorStops = arrayOf(
0.0f to Color.White.copy(alpha = if (isDark) 0.25f else 0.40f),
0.5f to Color.White.copy(alpha = 0.05f),
1.0f to Color.Transparent,
),
center = lightCenter,
radius = radius * 0.8f,
),
)
// Bottom-right accent bleed
drawCircle(
brush = Brush.radialGradient(
colorStops = arrayOf(
0.0f to accent.primary.copy(alpha = if (isDark) 0.45f else 0.35f),
0.6f to Color.Transparent,
),
center = Offset(w * 0.72f, h * 0.78f),
radius = radius * 0.65f,
),
)
// Bottom shadow for volume (subtle, not harsh)
drawCircle(
brush = Brush.radialGradient(
colorStops = arrayOf(
0.0f to Color.Transparent,
0.55f to Color.Transparent,
0.85f to Color.Black.copy(alpha = if (isDark) 0.20f else 0.08f),
1.0f to Color.Black.copy(alpha = if (isDark) 0.35f else 0.15f),
),
center = Offset(w * 0.45f, h * 0.40f),
radius = radius,
),
)
// Caustic conic layer (depth structure)
val coreShiftX = tiltX * 0.05f * w
val coreShiftY = -tiltY * 0.05f * h
val coreCenter = Offset(w / 2f + coreShiftX, h / 2f + coreShiftY)
drawCircle(
brush = Brush.sweepGradient(
colorStops = arrayOf(
0.0f to Color.Transparent,
0.16f to accent.primary.copy(alpha = 0.30f),
0.33f to Color.White.copy(alpha = 0.20f),
0.50f to Color.Transparent,
0.66f to accent.primaryDeep.copy(alpha = 0.25f),
1.0f to Color.Transparent,
),
center = coreCenter,
),
radius = radius * 0.84f,
blendMode = BlendMode.Overlay,
)
}
// --- LAYER 3: Specular highlights + rim ---
Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.toPx()
val h = size.toPx()
val radius = w / 2f
val specShiftX = -tiltX * 0.08f * w
val specShiftY = tiltY * 0.08f * h
// Primary specular — elliptical for realism
drawOval(
brush = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = if (isDark) 0.70f else 0.90f),
Color.Transparent,
),
center = Offset(w * 0.30f + specShiftX, h * 0.24f + specShiftY),
radius = radius * 0.35f,
),
topLeft = Offset(w * 0.12f + specShiftX, h * 0.10f + specShiftY),
size = androidx.compose.ui.geometry.Size(radius * 0.7f, radius * 0.55f),
)
// Secondary bounce (bottom-right, warm)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color(0xFFFFD180).copy(alpha = 0.80f), // Bright warm amber light refraction
Color.Transparent
accent.primary.copy(alpha = if (isDark) 0.50f else 0.35f),
Color.Transparent,
),
center = Offset(w * 0.72f + specShiftX, h * 0.80f + specShiftY),
radius = radius * 0.25f,
),
center = specularCenter2,
radius = radius * 0.28f
)
)
// 3. Ultra-sharp outer rim (1px hairline highlight for crisp crystal reflection)
// Crisp rim hairline
drawCircle(
color = Color.White.copy(alpha = if (isDark) 0.12f else 0.45f),
color = Color.White.copy(alpha = if (isDark) 0.10f else 0.30f),
radius = radius - 0.5f,
style = Stroke(width = 1.5f)
style = Stroke(width = 1f),
)
// Inner rim (inset edge for refraction feel)
drawCircle(
color = Color.White.copy(alpha = if (isDark) 0.05f else 0.12f),
radius = radius - 2f,
style = Stroke(width = 0.5f),
)
}
}

View file

@ -7,6 +7,11 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@ -63,6 +68,7 @@ import dev.kaizen.app.net.Attachment
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.effect.kaizenShadow
import dev.kaizen.app.ui.motion.Durations
import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.ui.theme.LocalKaizenAccent
@ -82,6 +88,20 @@ import androidx.compose.material.icons.rounded.PictureAsPdf
import androidx.compose.material.icons.rounded.VideoFile
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Mic
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.ui.platform.LocalClipboard
import dev.kaizen.app.haptics.rememberHaptics
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Psychology
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
@ -142,14 +162,24 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
}
@Composable
fun ModePillsRow(selected: Set<Int>, onToggle: (Int) -> Unit, modifier: Modifier = Modifier) {
fun ModePillsRow(
activeMode: Int?,
onToggle: (Int) -> Unit,
reasoningPreset: ReasoningPreset,
onReasoningChange: (ReasoningPreset) -> Unit,
samplingPrefs: SamplingPrefs,
onSamplingChange: (SamplingPrefs) -> Unit,
modifier: Modifier = Modifier,
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
val glassBorder = remember(isDark) {
if (isDark) Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.14f), Color.White.copy(alpha = 0.04f)))
else Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.50f), Color.Black.copy(alpha = 0.06f)))
}
var showReasoningMenu by remember { mutableStateOf(false) }
var showSamplingPopup by remember { mutableStateOf(false) }
val inactiveBg = if (isDark) cs.surface.copy(alpha = 0.35f) else cs.surface.copy(alpha = 0.55f)
val inactiveBorder = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
Row(
modifier
@ -158,25 +188,98 @@ fun ModePillsRow(selected: Set<Int>, onToggle: (Int) -> Unit, modifier: Modifier
.padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
chatModes.forEach { mode ->
val label = stringResource(mode.labelRes)
val active = mode.labelRes in selected
val backgroundFill = if (active) {
if (isDark) cs.surface.copy(alpha = 0.8f) else cs.surface.copy(alpha = 0.88f)
} else {
if (isDark) cs.surface.copy(alpha = 0.35f) else cs.surface.copy(alpha = 0.55f)
// Reasoning pill (dropdown)
Box {
Row(
Modifier
.clip(KaizenShapes.pill)
.background(inactiveBg)
.border(1.2.dp, inactiveBorder, KaizenShapes.pill)
.clickable { showReasoningMenu = true }
.padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Rounded.Psychology, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(reasoningPreset.label), color = cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.width(4.dp))
Icon(Icons.Rounded.ExpandMore, null, tint = cs.onSurfaceVariant.copy(alpha = 0.5f), modifier = Modifier.size(14.dp))
}
DropdownMenu(expanded = showReasoningMenu, onDismissRequest = { showReasoningMenu = false }) {
ReasoningPreset.entries.forEach { preset ->
DropdownMenuItem(
text = {
Text(
stringResource(preset.label),
fontSize = 14.sp,
color = if (preset == reasoningPreset) accent.primary else cs.onBackground,
fontWeight = if (preset == reasoningPreset) FontWeight.SemiBold else FontWeight.Normal,
)
},
onClick = { onReasoningChange(preset); showReasoningMenu = false },
)
}
}
}
// Sampling pill (popover)
Box {
val anyEnabled = samplingPrefs.temperature.enabled || samplingPrefs.topP.enabled || samplingPrefs.topK.enabled
Row(
Modifier
.clip(KaizenShapes.pill)
.background(if (anyEnabled) accent.primary.copy(alpha = if (isDark) 0.18f else 0.14f) else inactiveBg)
.border(1.2.dp, if (anyEnabled) accent.primary.copy(alpha = 0.4f) else inactiveBorder, KaizenShapes.pill)
.clickable { showSamplingPopup = !showSamplingPopup }
.padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Rounded.Tune, null, tint = if (anyEnabled) accent.primary else cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.mode_sampling), color = if (anyEnabled) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.width(4.dp))
Icon(Icons.Rounded.ExpandMore, null, tint = cs.onSurfaceVariant.copy(alpha = 0.5f), modifier = Modifier.size(14.dp))
}
DropdownMenu(expanded = showSamplingPopup, onDismissRequest = { showSamplingPopup = false }) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp).width(240.dp)) {
SamplingRow(
label = stringResource(R.string.sampling_temperature),
param = samplingPrefs.temperature, min = 0f, max = 2f, steps = 200,
onUpdate = { onSamplingChange(samplingPrefs.copy(temperature = it)) },
)
Spacer(Modifier.height(12.dp))
SamplingRow(
label = stringResource(R.string.sampling_top_p),
param = samplingPrefs.topP, min = 0f, max = 1f, steps = 100,
onUpdate = { onSamplingChange(samplingPrefs.copy(topP = it)) },
)
Spacer(Modifier.height(12.dp))
SamplingRow(
label = stringResource(R.string.sampling_top_k),
param = samplingPrefs.topK, min = 1f, max = 100f, steps = 99,
onUpdate = { onSamplingChange(samplingPrefs.copy(topK = it)) },
)
}
}
}
// Mode pills (Bild, Suche, Video, Audio)
chatModes.filter { it.labelRes != R.string.mode_standard && it.labelRes != R.string.mode_sampling }.forEach { mode ->
val label = stringResource(mode.labelRes)
val active = mode.labelRes == activeMode
val backgroundFill = if (active) accent.primary.copy(alpha = if (isDark) 0.18f else 0.14f) else inactiveBg
val borderColor = if (active) accent.primary.copy(alpha = 0.4f) else inactiveBorder
Row(
Modifier
.clip(KaizenShapes.pill)
.background(backgroundFill)
.border(1.2.dp, glassBorder, KaizenShapes.pill)
.border(1.2.dp, borderColor, KaizenShapes.pill)
.clickable { onToggle(mode.labelRes) }
.padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(mode.icon, null, tint = if (active) cs.onBackground else cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
Icon(mode.icon, null, tint = if (active) accent.primary else cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(label, color = if (active) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium)
}
@ -185,42 +288,164 @@ fun ModePillsRow(selected: Set<Int>, onToggle: (Int) -> Unit, modifier: Modifier
}
@Composable
fun MessageRow(message: Message) {
private fun SamplingRow(label: String, param: SamplingParam, min: Float, max: Float, steps: Int, onUpdate: (SamplingParam) -> Unit) {
val cs = MaterialTheme.colorScheme
val accent = LocalKaizenAccent.current
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = param.enabled, onCheckedChange = { onUpdate(param.copy(enabled = it)) }, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(label, color = cs.onBackground, fontSize = 13.sp, modifier = Modifier.weight(1f))
Text(
if (max > 2f) param.value.toInt().toString() else "%.2f".format(param.value),
color = cs.onSurfaceVariant, fontSize = 12.sp,
)
}
Slider(
value = param.value, onValueChange = { onUpdate(param.copy(value = it)) },
valueRange = min..max, steps = steps,
enabled = param.enabled,
colors = SliderDefaults.colors(thumbColor = accent.primary, activeTrackColor = accent.primary),
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
fun TokenBadge(used: Int, contextLength: Int, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
fun formatK(n: Int): String = when {
n >= 1_000_000 -> "${n / 1_000_000}M"
n >= 1_000 -> "${n / 1_000}k"
else -> "$n"
}
GlassSurface(
modifier = modifier,
shape = KaizenShapes.pill,
tintAlpha = GlassTiers.pill(isDark),
shadowStack = KaizenShadows.level1,
cornerRadius = 16.dp,
) {
Row(
Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Rounded.Bolt, null, tint = accent.primary, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text(
"${formatK(used)} / ${formatK(contextLength)}",
color = cs.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
@Composable
fun MessageRow(
message: Message,
onCopy: (String) -> Unit = {},
onDelete: ((String) -> Unit)? = null,
onRegenerate: ((String) -> Unit)? = null,
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
val hasAttachments = message.attachments.isNotEmpty()
val clipboard = LocalClipboard.current
val haptics = rememberHaptics()
val scope = rememberCoroutineScope()
var copied by remember { mutableStateOf(false) }
if (message.role == Role.User) {
Column(horizontalAlignment = Alignment.End, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
val shape = KaizenShapes.lg
val borderBrush = remember(accent) { Brush.verticalGradient(listOf(accent.primary.copy(alpha = 0.45f), accent.primaryDeep.copy(alpha = 0.15f))) }
val bgBrush = remember(accent) { Brush.verticalGradient(listOf(accent.primary.copy(alpha = 0.18f), accent.primaryDeep.copy(alpha = 0.08f))) }
val bgBrush = remember(accent, isDark) {
Brush.verticalGradient(
listOf(
if (isDark) accent.primary.copy(alpha = 0.28f) else accent.primary.copy(alpha = 0.16f),
if (isDark) accent.primaryDeep.copy(alpha = 0.22f) else accent.primaryDeep.copy(alpha = 0.10f),
)
)
}
val borderBrush = remember(accent, isDark) {
Brush.verticalGradient(
listOf(
accent.primary.copy(alpha = if (isDark) 0.55f else 0.40f),
accent.primaryDeep.copy(alpha = if (isDark) 0.15f else 0.10f),
)
)
}
val innerHighlight = remember(accent, isDark) {
Brush.verticalGradient(
listOf(
accent.primary.copy(alpha = if (isDark) 0.12f else 0.08f),
Color.Transparent,
)
)
}
Box(
Modifier
.widthIn(max = 300.dp)
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 20.dp)
.clip(shape)
.background(bgBrush)
.background(innerHighlight)
.border(1.2.dp, borderBrush, shape),
) {
Column {
if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp))
if (message.content.isNotBlank()) {
Text(
message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (hasAttachments) 4.dp else 11.dp, bottom = 11.dp),
message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 26.sp,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (hasAttachments) 4.dp else 13.dp, bottom = 13.dp),
)
}
}
}
}
if (!message.streaming) {
Row(
Modifier.padding(top = 4.dp, end = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
MessageActionButton(
icon = if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy,
tint = if (copied) Color(0xFF10B981) else cs.onSurfaceVariant.copy(alpha = 0.35f),
) {
haptics.tick()
copied = true
scope.launch {
clipboard.setClipEntry(androidx.compose.ui.platform.ClipEntry(android.content.ClipData.newPlainText("msg", message.content)))
kotlinx.coroutines.delay(1500)
copied = false
}
}
onDelete?.let { del ->
MessageActionButton(Icons.Rounded.Delete, Color(0xFFEF4444).copy(alpha = 0.6f)) {
haptics.tick(); del(message.wireId)
}
}
}
}
}
} else {
Column {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
KaizenOrb(28.dp, streaming = message.streaming)
Spacer(Modifier.width(10.dp))
Box(Modifier.weight(1f).padding(top = 3.dp)) {
Column {
if (message.reasoning.isNotBlank()) {
ReasoningBlock(message.reasoning, streaming = message.streaming)
Spacer(Modifier.height(8.dp))
}
if (message.tools.isNotEmpty()) {
ToolEventsBlock(message.tools, streaming = message.streaming)
Spacer(Modifier.height(8.dp))
@ -231,10 +456,6 @@ fun MessageRow(message: Message) {
}
if (message.thinking) TypingDots()
else MarkdownText(text = message.content, streaming = message.streaming)
if (message.reasoning.isNotBlank()) {
Spacer(Modifier.height(8.dp))
ReasoningBlock(message.reasoning, streaming = message.streaming)
}
if (message.sources.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
SourcesBlock(message.sources, query = message.query)
@ -242,6 +463,49 @@ fun MessageRow(message: Message) {
}
}
}
if (!message.streaming && !message.thinking && message.content.isNotBlank()) {
Row(
Modifier.padding(start = 38.dp, top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
MessageActionButton(
icon = if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy,
tint = if (copied) Color(0xFF10B981) else cs.onSurfaceVariant.copy(alpha = 0.35f),
) {
haptics.tick()
copied = true
scope.launch {
clipboard.setClipEntry(androidx.compose.ui.platform.ClipEntry(android.content.ClipData.newPlainText("msg", message.content)))
kotlinx.coroutines.delay(1500)
copied = false
}
}
onRegenerate?.let { regen ->
MessageActionButton(Icons.Rounded.Refresh, cs.onSurfaceVariant.copy(alpha = 0.35f)) {
haptics.tick(); regen(message.wireId)
}
}
onDelete?.let { del ->
MessageActionButton(Icons.Rounded.Delete, Color(0xFFEF4444).copy(alpha = 0.6f)) {
haptics.tick(); del(message.wireId)
}
}
}
}
}
}
}
@Composable
private fun MessageActionButton(icon: ImageVector, tint: Color, onClick: () -> Unit) {
Box(
Modifier
.size(30.dp)
.clip(KaizenShapes.circle)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(icon, null, tint = tint, modifier = Modifier.size(16.dp))
}
}
@ -288,10 +552,13 @@ fun ChatInput(
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val hasContent = value.isNotBlank() || pendingFiles.any { it.uploaded != null }
val canSend = enabled && hasContent && pendingFiles.none { it.uploading }
GlassSurface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
.padding(horizontal = 8.dp),
shape = KaizenShapes.xl,
tintAlpha = GlassTiers.input(isDark),
shadowStack = KaizenShadows.level2,
@ -300,39 +567,35 @@ fun ChatInput(
Column {
if (pendingFiles.isNotEmpty()) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 8.dp),
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 14.dp, end = 14.dp, top = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
}
}
Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(40.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) {
Row(Modifier.padding(start = 6.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), verticalAlignment = Alignment.Bottom) {
Box(Modifier.size(42.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Add, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
}
Box(Modifier.weight(1f).heightIn(min = 36.dp).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) {
if (value.isEmpty()) Text(stringResource(R.string.chat_input_placeholder), color = cs.onSurfaceVariant, fontSize = 16.sp)
Box(Modifier.weight(1f).heightIn(min = 42.dp).padding(horizontal = 6.dp), contentAlignment = Alignment.CenterStart) {
if (value.isEmpty()) Text(stringResource(R.string.chat_input_placeholder), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 15.sp)
val accent = LocalKaizenAccent.current
BasicTextField(
value = value, onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp),
cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(),
)
}
GhostIconButton(Icons.Rounded.Call, stringResource(R.string.chat_call))
Spacer(Modifier.width(4.dp))
val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) && pendingFiles.none { it.uploading }
if (hasContent) {
SendButton(enabled = canSend, onClick = onSend)
} else {
Box(Modifier.size(44.dp).clip(KaizenShapes.circle), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Mic, stringResource(R.string.chat_call), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
}
}
}
}
@Composable
private fun GhostIconButton(icon: ImageVector, contentDescription: String) {
val cs = MaterialTheme.colorScheme
Box(Modifier.size(40.dp).clip(KaizenShapes.circle), contentAlignment = Alignment.Center) {
Icon(icon, contentDescription, tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
}
}

View file

@ -18,6 +18,25 @@ import dev.kaizen.app.net.ToolEvent
enum class Role { User, Assistant }
enum class ReasoningPreset(val label: Int, val effort: String?, val throughput: Boolean) {
Instant(R.string.reasoning_instant, "none", true),
None(R.string.reasoning_none, "none", false),
Minimal(R.string.reasoning_minimal, "minimal", false),
Low(R.string.reasoning_low, "low", false),
Standard(R.string.reasoning_standard, null, false),
Medium(R.string.reasoning_medium, "medium", false),
High(R.string.reasoning_high, "high", false),
XHigh(R.string.reasoning_xhigh, "xhigh", false),
}
data class SamplingParam(val enabled: Boolean, val value: Float)
data class SamplingPrefs(
val temperature: SamplingParam = SamplingParam(false, 1f),
val topP: SamplingParam = SamplingParam(false, 1f),
val topK: SamplingParam = SamplingParam(false, 40f),
)
data class Message(
val id: Long,
val role: Role,

View file

@ -13,6 +13,7 @@ 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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -23,6 +24,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
@ -96,7 +99,10 @@ fun ChatScreen(
var input by remember { mutableStateOf("") }
var isStreaming by remember { mutableStateOf(false) }
var nextId by remember { mutableStateOf(0L) }
var selectedModes by remember { mutableStateOf(setOf(R.string.mode_standard)) }
var activeMode by remember { mutableStateOf<Int?>(null) }
var reasoningPreset by remember { mutableStateOf(ReasoningPreset.Standard) }
var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) }
var usedTokens by remember { mutableStateOf(0) }
// Navigation State
var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
@ -117,6 +123,7 @@ fun ChatScreen(
// 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) }
@ -194,7 +201,12 @@ fun ChatScreen(
session.config?.let { cfg ->
scope.launch { KaizenApi.prewarm(cfg.baseUrl) }
}
onPauseOrDispose { }
onPauseOrDispose {
if (viewingLockedChat) {
messages.clear()
viewingLockedChat = false
}
}
}
fun refreshConversations() {
@ -206,14 +218,58 @@ fun ChatScreen(
fun newChat() {
messages.clear()
conversationId = null
viewingLockedChat = false
}
fun openConversation(summary: ConversationSummary) {
if (summary.locked) return
val cfg = session.config ?: return
if (summary.locked) {
val activity = context as? androidx.fragment.app.FragmentActivity ?: 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 -> {
scope.launch {
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token)
if (unlockToken == null) {
loadError = context.getString(R.string.biometric_failed)
return@launch
}
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
}
}
}
is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> {
loadError = context.getString(R.string.biometric_not_available)
}
is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled or failed, do nothing */ }
}
}
return
}
viewingLockedChat = false
scope.launch {
// Instant: load from Room cache first
val cached = chat.messageRepo.observeMessages(summary.id)
messages.clear()
val cachedList = chat.messageRepo.getCached(summary.id)
cachedList.forEach { m ->
@ -224,16 +280,16 @@ fun ChatScreen(
content = m.content,
wireId = m.id,
attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
),
)
}
conversationId = summary.id
// Background: refresh from server and update Room + UI
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)
// Only rebuild message list if content actually changed
if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) {
messages.clear()
stored.forEach { m ->
@ -244,6 +300,8 @@ fun ChatScreen(
content = m.content,
wireId = m.id,
attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
),
)
}
@ -284,8 +342,14 @@ fun ChatScreen(
} else null
KaizenApi.chat(
cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed,
cfg.baseUrl, cfg.token, cfg.model, history,
reasoningEffort = reasoningPreset.effort,
preferThroughput = reasoningPreset.throughput,
attachments = uploadedAtts.takeIf { it.isNotEmpty() },
webSearch = activeMode == R.string.mode_search,
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
@ -301,9 +365,15 @@ fun ChatScreen(
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 finalContent = if (idx >= 0) messages[idx].content else ""
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 { conversationId = it } ?: conversationId
@ -317,11 +387,15 @@ fun ChatScreen(
role = "user", content = trimmed,
attachments = uploadedAtts.takeIf { it.isNotEmpty() },
),
SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model),
SaveMessage(
id = assistantWireId, parentId = userMsg.wireId,
role = "assistant", content = finalContent, model = cfg.model,
reasoning = finalReasoning,
sources = finalSources,
),
),
)
if (saved) {
// Cache new messages in Room for offline-first
val msgCount = messages.size
chat.messageRepo.cacheMessages(convId, listOf(
MessageEntity(
@ -333,6 +407,8 @@ fun ChatScreen(
id = assistantWireId, conversationId = convId,
role = "assistant", content = finalContent,
attachments = emptyList(), sortOrder = msgCount - 1,
reasoning = finalReasoning,
sources = finalSources ?: emptyList(),
),
))
if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
@ -409,7 +485,32 @@ fun ChatScreen(
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 -> {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to action.locked))
refreshConversations()
}
}
}
},
)
},
gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer
@ -441,7 +542,73 @@ fun ChatScreen(
),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(messages, key = { it.id }) { message -> MessageRow(message) }
items(messages, key = { it.id }) { message ->
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,
)
}
}
// Scroll buttons
val showScrollUp by remember { derivedStateOf { listState.firstVisibleItemIndex > 2 } }
val showScrollDown by remember { derivedStateOf {
val info = listState.layoutInfo
info.visibleItemsInfo.lastOrNull()?.index != info.totalItemsCount - 1
}}
if (showScrollUp || showScrollDown) {
Column(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (showScrollUp) {
GlassSurface(
shape = KaizenShapes.circle,
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
shadowStack = KaizenShadows.level1,
cornerRadius = 18.dp,
modifier = Modifier
.size(36.dp)
.clickable { scope.launch { listState.animateScrollToItem(0) } },
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.KeyboardArrowUp, null, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.size(20.dp))
}
}
}
if (showScrollDown) {
GlassSurface(
shape = KaizenShapes.circle,
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
shadowStack = KaizenShadows.level1,
cornerRadius = 18.dp,
modifier = Modifier
.size(36.dp)
.clickable { scope.launch { listState.animateScrollToItem(messages.size - 1) } },
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.KeyboardArrowDown, null, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.size(20.dp))
}
}
}
}
}
}
@ -490,6 +657,11 @@ fun ChatScreen(
onClick = { haptics.tick(); showModelSheet = true },
modifier = Modifier.weight(1f, fill = false),
)
if (usedTokens > 0 || messages.isNotEmpty()) {
val ctxLen = models.find { it.id == session.config?.model }?.context_length ?: 1_000_000
Spacer(Modifier.width(8.dp))
TokenBadge(used = usedTokens, contextLength = ctxLen)
}
}
}
@ -529,15 +701,20 @@ fun ChatScreen(
)
)
.navigationBarsPadding()
.imePadding()
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
) {
Spacer(Modifier.height(24.dp))
ModePillsRow(
selected = selectedModes,
activeMode = activeMode,
onToggle = { mode ->
selectedModes = if (mode in selectedModes) selectedModes - mode else selectedModes + mode
activeMode = if (activeMode == mode) null else mode
haptics.tick()
},
reasoningPreset = reasoningPreset,
onReasoningChange = { reasoningPreset = it; haptics.tick() },
samplingPrefs = samplingPrefs,
onSamplingChange = { samplingPrefs = it },
)
Spacer(Modifier.height(10.dp))
ChatInput(

View file

@ -363,20 +363,20 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
val isLast = idx == blocks.lastIndex
when (block) {
is MdBlock.Heading -> {
if (idx > 0) Spacer(Modifier.height(8.dp))
if (idx > 0) Spacer(Modifier.height(18.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg)
Text(
text = rendered,
color = cs.onBackground,
fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp },
fontWeight = FontWeight.Bold,
lineHeight = when (block.level) { 1 -> 28.sp; 2 -> 25.sp; else -> 23.sp },
lineHeight = when (block.level) { 1 -> 30.sp; 2 -> 27.sp; else -> 24.sp },
)
Spacer(Modifier.height(4.dp))
Spacer(Modifier.height(8.dp))
}
is MdBlock.CodeFence -> {
if (idx > 0) Spacer(Modifier.height(6.dp))
if (idx > 0) Spacer(Modifier.height(12.dp))
Box(
Modifier
.fillMaxWidth()
@ -416,13 +416,13 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
}
}
}
Spacer(Modifier.height(6.dp))
Spacer(Modifier.height(12.dp))
}
is MdBlock.ListBlock -> {
if (idx > 0) Spacer(Modifier.height(4.dp))
if (idx > 0) Spacer(Modifier.height(8.dp))
block.items.forEachIndexed { itemIdx, item ->
Row(Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 3.dp)) {
Row(Modifier.fillMaxWidth().padding(start = 6.dp, bottom = 6.dp)) {
val bullet = if (item.ordered) "${item.index}." else ""
Text(
bullet,
@ -435,27 +435,27 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
text = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground,
fontSize = 16.sp,
lineHeight = 23.sp,
lineHeight = 26.sp,
modifier = Modifier.weight(1f),
)
}
}
Spacer(Modifier.height(4.dp))
Spacer(Modifier.height(8.dp))
}
is MdBlock.HorizontalRule -> {
if (idx > 0) Spacer(Modifier.height(8.dp))
if (idx > 0) Spacer(Modifier.height(14.dp))
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(cs.onSurfaceVariant.copy(alpha = 0.25f))
.background(cs.onSurfaceVariant.copy(alpha = 0.20f))
)
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(14.dp))
}
is MdBlock.Blockquote -> {
if (idx > 0) Spacer(Modifier.height(6.dp))
if (idx > 0) Spacer(Modifier.height(12.dp))
Row(
Modifier
.fillMaxWidth()
@ -474,21 +474,21 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg),
color = cs.onSurfaceVariant,
fontSize = 15.sp,
lineHeight = 22.sp,
lineHeight = 24.sp,
fontStyle = FontStyle.Italic,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(6.dp))
Spacer(Modifier.height(12.dp))
}
is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(6.dp))
if (idx > 0) Spacer(Modifier.height(12.dp))
Text(
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground,
fontSize = 16.sp,
lineHeight = 23.sp,
lineHeight = 26.sp,
)
}

View file

@ -19,25 +19,43 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Logout
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.PushPin
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.ConversationSummary
@ -76,6 +94,13 @@ private fun groupConversations(conversations: List<ConversationSummary>, labels:
return groups
}
sealed interface SidebarAction {
data class Rename(val id: String, val newTitle: String) : SidebarAction
data class Delete(val id: String) : SidebarAction
data class TogglePin(val id: String, val pinned: Boolean) : SidebarAction
data class ToggleLock(val id: String, val locked: Boolean) : SidebarAction
}
@Composable
fun KaizenSidebar(
userName: String,
@ -86,18 +111,23 @@ fun KaizenSidebar(
onSelectConversation: (ConversationSummary) -> Unit,
onOpenSettings: () -> Unit,
onLogout: () -> Unit,
onAction: (SidebarAction) -> Unit = {},
modifier: Modifier = Modifier
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
var deleteTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameText by remember { mutableStateOf("") }
GlassSurface(
modifier = modifier
.fillMaxHeight()
.width(300.dp)
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
shape = KaizenShapes.lg,
tintAlpha = GlassTiers.sidebar(isDark),
tintAlpha = if (isDark) 0.88f else 0.92f,
shadowStack = KaizenShadows.level3,
cornerRadius = 24.dp,
) {
@ -113,46 +143,50 @@ fun KaizenSidebar(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.sidebar_brand), color = cs.onBackground, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text(
stringResource(R.string.sidebar_brand),
color = cs.onBackground,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Box(
modifier = Modifier
.size(36.dp)
.size(34.dp)
.clip(KaizenShapes.circle)
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
.clickable { onNewChat() },
contentAlignment = Alignment.Center
) {
Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(18.dp))
Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(17.dp))
}
}
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(14.dp))
// Search
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.sm)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.sm)
.padding(horizontal = 12.dp, vertical = 10.dp),
.clip(KaizenShapes.pill)
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x08000000))
.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(18.dp))
Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.45f), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 14.sp)
Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.45f), fontSize = 13.sp)
}
Spacer(Modifier.height(24.dp))
Spacer(Modifier.height(18.dp))
// Conversation list
Box(Modifier.weight(1f).fillMaxWidth()) {
if (conversations.isEmpty()) {
Text(
stringResource(R.string.chat_no_conversations),
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 14.sp,
modifier = Modifier.padding(start = 12.dp, top = 12.dp)
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
fontSize = 13.sp,
modifier = Modifier.padding(start = 8.dp, top = 8.dp)
)
} else {
val labels = DateLabels(
@ -164,16 +198,16 @@ fun KaizenSidebar(
val grouped = remember(conversations, labels) { groupConversations(conversations, labels) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
grouped.forEach { (dateGroup, items) ->
item(key = "header_$dateGroup") {
Text(
dateGroup,
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 8.dp, top = 14.dp, bottom = 6.dp)
)
}
items(items, key = { it.id }) { item ->
@ -181,6 +215,13 @@ fun KaizenSidebar(
item = item,
selected = item.id == currentId,
onClick = { onSelectConversation(item) },
onRename = {
renameTarget = item
renameText = item.title
},
onDelete = { deleteTarget = item },
onTogglePin = { onAction(SidebarAction.TogglePin(item.id, !item.pinned)) },
onToggleLock = { onAction(SidebarAction.ToggleLock(item.id, !item.locked)) },
)
}
}
@ -188,99 +229,242 @@ fun KaizenSidebar(
}
}
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(12.dp))
// User card
// User card — compact footer with settings + logout
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.md)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.md)
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x06000000))
.clickable { onOpenSettings() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.size(30.dp)
.clip(KaizenShapes.circle)
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
contentAlignment = Alignment.Center
) {
Text(
userName.firstOrNull()?.toString()?.uppercase() ?: "A",
color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold
color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(userName, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Text(
userName,
color = cs.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Rounded.Settings,
stringResource(R.string.sidebar_settings),
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(6.dp))
Box(
modifier = Modifier
.size(28.dp)
.clip(KaizenShapes.circle)
.clickable { onLogout() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Rounded.Logout,
stringResource(R.string.sidebar_logout),
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(16.dp),
)
}
}
}
Icon(Icons.Rounded.Settings, stringResource(R.string.sidebar_settings), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(20.dp))
}
Spacer(Modifier.height(10.dp))
// Delete confirmation dialog
deleteTarget?.let { target ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(stringResource(R.string.sidebar_delete)) },
text = { Text(stringResource(R.string.sidebar_delete_confirm)) },
confirmButton = {
TextButton(onClick = {
onAction(SidebarAction.Delete(target.id))
deleteTarget = null
}) {
Text(stringResource(R.string.sidebar_confirm), color = Color(0xFFEF4444))
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text(stringResource(R.string.sidebar_cancel))
}
},
)
}
// Logout
Row(
// Rename dialog
renameTarget?.let { target ->
val focusManager = LocalFocusManager.current
AlertDialog(
onDismissRequest = { renameTarget = null },
title = { Text(stringResource(R.string.sidebar_rename_title)) },
text = {
BasicTextField(
value = renameText,
onValueChange = { renameText = it },
singleLine = true,
textStyle = TextStyle(
color = cs.onBackground,
fontSize = 15.sp,
),
cursorBrush = SolidColor(cs.onBackground),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
focusManager.clearFocus()
if (renameText.isNotBlank()) {
onAction(SidebarAction.Rename(target.id, renameText.trim()))
}
renameTarget = null
}),
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.sm)
.clickable { onLogout() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.AutoMirrored.Rounded.Logout, stringResource(R.string.sidebar_logout), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text(stringResource(R.string.sidebar_logout), color = cs.onSurfaceVariant.copy(alpha = 0.85f), fontSize = 14.sp, fontWeight = FontWeight.Medium)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x08000000))
.padding(horizontal = 14.dp, vertical = 12.dp),
)
},
confirmButton = {
TextButton(onClick = {
if (renameText.isNotBlank()) {
onAction(SidebarAction.Rename(target.id, renameText.trim()))
}
renameTarget = null
}) {
Text(stringResource(R.string.sidebar_save))
}
},
dismissButton = {
TextButton(onClick = { renameTarget = null }) {
Text(stringResource(R.string.sidebar_cancel))
}
},
)
}
}
@Composable
private fun HistoryItemRow(item: ConversationSummary, selected: Boolean, onClick: () -> Unit) {
private fun HistoryItemRow(
item: ConversationSummary,
selected: Boolean,
onClick: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
onTogglePin: () -> Unit,
onToggleLock: () -> Unit,
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
var showMenu by remember { mutableStateOf(false) }
val itemBg = when {
selected -> accent.primary.copy(alpha = 0.14f)
isDark -> Color(0x0AFFFFFF)
else -> Color(0x05000000)
}
val itemBorder = when {
selected -> accent.primary.copy(alpha = 0.35f)
isDark -> Color.White.copy(alpha = 0.04f)
else -> Color.Black.copy(alpha = 0.03f)
selected -> accent.primary.copy(alpha = if (isDark) 0.14f else 0.10f)
else -> Color.Transparent
}
Box {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.pill)
.clip(KaizenShapes.sm)
.background(itemBg)
.border(1.2.dp, itemBorder, KaizenShapes.pill)
.clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 11.dp),
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item.pinned) {
Icon(Icons.Rounded.Star, null, tint = accent.primary, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(8.dp))
Icon(Icons.Rounded.Star, null, tint = accent.primary.copy(alpha = 0.7f), modifier = Modifier.size(13.dp))
Spacer(Modifier.width(6.dp))
}
Text(
text = if (item.locked) stringResource(R.string.chat_locked) else item.title,
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.5f) else cs.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.45f) else cs.onBackground.copy(alpha = 0.85f),
fontSize = 13.5.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
modifier = Modifier.weight(1f)
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (item.locked) {
Spacer(Modifier.width(8.dp))
Icon(Icons.Rounded.Lock, stringResource(R.string.chat_locked_badge), tint = accent.primary, modifier = Modifier.size(15.dp))
Icon(Icons.Rounded.Lock, null, tint = accent.primary.copy(alpha = 0.5f), modifier = Modifier.size(13.dp))
Spacer(Modifier.width(2.dp))
}
Box(
modifier = Modifier
.size(28.dp)
.clip(KaizenShapes.circle)
.clickable { showMenu = true },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Rounded.MoreVert,
stringResource(R.string.sidebar_more),
tint = cs.onSurfaceVariant.copy(alpha = 0.35f),
modifier = Modifier.size(16.dp),
)
}
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
if (!item.locked) {
DropdownMenuItem(
text = { Text(stringResource(R.string.sidebar_rename), fontSize = 14.sp) },
onClick = { showMenu = false; onRename() },
leadingIcon = { Icon(Icons.Rounded.Edit, null, modifier = Modifier.size(18.dp)) },
)
}
DropdownMenuItem(
text = {
Text(
stringResource(if (item.pinned) R.string.sidebar_unpin else R.string.sidebar_pin),
fontSize = 14.sp,
)
},
onClick = { showMenu = false; onTogglePin() },
leadingIcon = { Icon(Icons.Rounded.PushPin, null, modifier = Modifier.size(18.dp)) },
)
DropdownMenuItem(
text = {
Text(
stringResource(if (item.locked) R.string.sidebar_unlock else R.string.sidebar_lock),
fontSize = 14.sp,
)
},
onClick = { showMenu = false; onToggleLock() },
leadingIcon = {
Icon(
if (item.locked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
null,
modifier = Modifier.size(18.dp),
)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.sidebar_delete), fontSize = 14.sp, color = Color(0xFFEF4444)) },
onClick = { showMenu = false; onDelete() },
leadingIcon = { Icon(Icons.Rounded.Delete, null, tint = Color(0xFFEF4444), modifier = Modifier.size(18.dp)) },
)
}
}
}

View file

@ -79,7 +79,7 @@ fun ReasoningBlock(reasoning: String, streaming: Boolean = false) {
Icon(Icons.Rounded.Psychology, null, tint = accent.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Text(
"Gedankengang",
stringResource(R.string.stream_reasoning),
color = cs.onSurfaceVariant,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,

View file

@ -2,6 +2,7 @@ package dev.kaizen.app.db
import androidx.room.TypeConverter
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@ -15,4 +16,12 @@ class Converters {
@TypeConverter
fun jsonToAttachments(value: String): List<Attachment> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
@TypeConverter
fun sourcesToJson(sources: List<SearchSource>): String =
json.encodeToString(sources)
@TypeConverter
fun jsonToSources(value: String): List<SearchSource> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
}

View file

@ -5,6 +5,7 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
@Entity(tableName = "conversations")
data class ConversationEntity(
@ -33,4 +34,6 @@ data class MessageEntity(
val content: String,
val attachments: List<Attachment>,
val sortOrder: Int,
val reasoning: String? = null,
val sources: List<SearchSource> = emptyList(),
)

View file

@ -27,6 +27,8 @@ fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEnti
content = content,
attachments = attachments,
sortOrder = sortOrder,
reasoning = reasoning,
sources = sources ?: emptyList(),
)
fun MessageEntity.toStoredMessage() = StoredMessage(
@ -34,4 +36,6 @@ fun MessageEntity.toStoredMessage() = StoredMessage(
role = role,
content = content,
attachments = attachments,
reasoning = reasoning,
sources = sources.takeIf { it.isNotEmpty() },
)

View file

@ -35,6 +35,10 @@ import java.util.concurrent.TimeUnit
val reasoningEffort: String? = null,
val preferThroughput: Boolean? = null,
val attachments: List<Attachment>? = null,
val webSearch: Boolean? = null,
val temperature: Float? = null,
val topP: Float? = null,
val topK: Int? = null,
)
/** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */
@ -43,6 +47,7 @@ data class KaizenModel(
val id: String,
val name: String? = null,
val provider: String? = null,
val context_length: Int? = null,
)
@Serializable private data class ModelsResponse(val models: List<KaizenModel> = emptyList())
@ -58,6 +63,7 @@ data class ConversationSummary(
)
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
@Serializable private data class UnlockResponse(val unlockToken: String = "")
@Serializable private data class CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String)
@ -79,6 +85,8 @@ data class StoredMessage(
val role: String,
val content: String = "",
val attachments: List<Attachment> = emptyList(),
val reasoning: String? = null,
val sources: List<SearchSource>? = null,
)
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
@ -93,6 +101,8 @@ data class SaveMessage(
val kind: String = "chat",
val model: String? = null,
val attachments: List<Attachment>? = null,
val reasoning: String? = null,
val sources: List<SearchSource>? = null,
)
@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>)
@ -316,6 +326,108 @@ object KaizenApi {
}
}
/** PATCH /api/v1/conversations/[id] — update title, locked, pinned. */
suspend fun patchConversation(baseUrl: String, token: String, id: String, body: Map<String, Any?>): Boolean =
withContext(Dispatchers.IO) {
val payload = json.encodeToString(kotlinx.serialization.json.JsonObject(
body.mapValues { (_, v) ->
when (v) {
is String -> kotlinx.serialization.json.JsonPrimitive(v)
is Boolean -> kotlinx.serialization.json.JsonPrimitive(v)
else -> kotlinx.serialization.json.JsonNull
}
}
)).toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id")
.patch(payload)
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "patchConversation($id): ${e.javaClass.simpleName}: ${e.message}")
false
}
}
/** DELETE /api/v1/conversations/[id] — permanently remove a conversation. */
suspend fun deleteConversation(baseUrl: String, token: String, id: String): Boolean =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id")
.delete()
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "deleteConversation($id): ${e.javaClass.simpleName}: ${e.message}")
false
}
}
/** POST /api/v1/auth/unlock — get a short-lived unlock token for locked conversations. */
suspend fun requestUnlock(baseUrl: String, token: String): String? =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/auth/unlock")
.post("{}".toRequestBody(jsonMedia))
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
Log.w(TAG, "requestUnlock: HTTP ${resp.code}")
return@use null
}
json.decodeFromString<UnlockResponse>(resp.body!!.string()).unlockToken
}
} catch (e: Exception) {
Log.w(TAG, "requestUnlock: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
/** GET /api/v1/conversations/[id] with unlock token — fetch messages of a locked conversation. */
suspend fun fetchLockedMessages(baseUrl: String, token: String, id: String, unlockToken: String): List<StoredMessage> =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id")
.header("Authorization", "Bearer $token")
.header("X-Unlock-Token", unlockToken)
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
Log.w(TAG, "fetchLockedMessages($id): HTTP ${resp.code}")
return@use emptyList()
}
json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages
}
} catch (e: Exception) {
Log.w(TAG, "fetchLockedMessages($id): ${e.javaClass.simpleName}: ${e.message}")
emptyList()
}
}
/** DELETE /api/v1/conversations/[id]/messages — delete a single message. */
suspend fun deleteMessage(baseUrl: String, token: String, conversationId: String, messageId: String): Boolean =
withContext(Dispatchers.IO) {
val body = """{"messageId":"$messageId"}""".toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$conversationId/messages")
.delete(body)
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "deleteMessage: ${e.javaClass.simpleName}: ${e.message}")
false
}
}
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
withContext(Dispatchers.IO) {
@ -348,16 +460,25 @@ object KaizenApi {
token: String,
model: String,
messages: List<WireMessage>,
speed: ChatSpeed = ChatSpeed.Normal,
reasoningEffort: String? = null,
preferThroughput: Boolean = false,
attachments: List<Attachment>? = null,
webSearch: Boolean = false,
temperature: Float? = null,
topP: Float? = null,
topK: Int? = null,
): Flow<StreamState> = flow {
val payload = json.encodeToString(
ChatRequest(
model = model,
messages = messages,
reasoningEffort = speed.reasoningEffort,
preferThroughput = if (speed.preferThroughput) true else null,
reasoningEffort = reasoningEffort,
preferThroughput = if (preferThroughput) true else null,
attachments = attachments?.takeIf { it.isNotEmpty() },
webSearch = if (webSearch) true else null,
temperature = temperature,
topP = topP,
topK = topK,
),
).toRequestBody(jsonMedia)
val req = Request.Builder()

View file

@ -28,12 +28,20 @@ data class SearchSource(
val snippet: String = "",
)
@Serializable
private data class UsageJson(
val i: Int = 0,
val o: Int = 0,
)
data class StreamState(
val content: String = "",
val reasoning: String = "",
val tools: List<ToolEvent> = emptyList(),
val query: String = "",
val sources: List<SearchSource> = emptyList(),
val inputTokens: Int = 0,
val outputTokens: Int = 0,
)
object StreamConsumer {
@ -101,12 +109,15 @@ object StreamConsumer {
private fun buildState(): StreamState {
val queryStr = parseTrailingField(QUERY)
val sourcesList = parseTrailingSources()
val usage = parseTrailingUsage()
return StreamState(
content = content.toString(),
reasoning = reasoningBuf.toString(),
tools = tools.toList(),
query = queryStr,
sources = sourcesList,
inputTokens = usage?.i ?: 0,
outputTokens = usage?.o ?: 0,
)
}
@ -135,6 +146,15 @@ object StreamConsumer {
}
}
private fun parseTrailingUsage(): UsageJson? {
val full = trailingBuf.toString()
val idx = full.indexOf(USAGE)
if (idx == -1) return null
val json = full.substring(idx + 1).trim()
if (json.isEmpty()) return null
return try { lenientJson.decodeFromString<UsageJson>(json) } catch (_: Exception) { null }
}
private fun parseToolEvents(buf: CharSequence, from: Int, to: Int) {
val s = buf.toString()
var pos = from

View file

@ -92,7 +92,7 @@ fun GlassSurface(
* Use these constants with [GlassSurface] instead of ad-hoc alpha values.
*/
object GlassTiers {
fun sidebar(isDark: Boolean) = if (isDark) 0.72f else 0.68f
fun sidebar(isDark: Boolean) = if (isDark) 0.88f else 0.92f
fun input(isDark: Boolean) = if (isDark) 0.75f else 0.74f
fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f
fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f

View file

@ -61,6 +61,41 @@
<string name="sidebar_earlier">EARLIER</string>
<string name="sidebar_logout">Sign Out</string>
<string name="sidebar_settings">Settings</string>
<string name="sidebar_rename">Rename</string>
<string name="sidebar_delete">Delete</string>
<string name="sidebar_pin">Pin</string>
<string name="sidebar_unpin">Unpin</string>
<string name="sidebar_lock">Lock</string>
<string name="sidebar_unlock">Unlock</string>
<string name="sidebar_more">More</string>
<string name="sidebar_rename_title">Rename Chat</string>
<string name="sidebar_delete_confirm">Permanently delete this chat?</string>
<string name="sidebar_cancel">Cancel</string>
<string name="sidebar_confirm">Confirm</string>
<string name="sidebar_save">Save</string>
<!-- Reasoning presets -->
<string name="reasoning_instant">Instant</string>
<string name="reasoning_none">Off</string>
<string name="reasoning_minimal">Minimal</string>
<string name="reasoning_low">Low</string>
<string name="reasoning_standard">Standard</string>
<string name="reasoning_medium">Medium</string>
<string name="reasoning_high">High</string>
<string name="reasoning_xhigh">Maximum</string>
<!-- Sampling -->
<string name="sampling_temperature">Temperature</string>
<string name="sampling_top_p">Top-P</string>
<string name="sampling_top_k">Top-K</string>
<!-- Biometric unlock -->
<string name="biometric_title">Unlock Chat</string>
<string name="biometric_subtitle">Confirm your identity</string>
<string name="biometric_cancel">Cancel</string>
<string name="biometric_not_available">Biometrics not available</string>
<string name="biometric_failed">Authentication failed</string>
<string name="unlock_tap_to_unlock">Tap to unlock</string>
<!-- Model sheet -->
<string name="model_sheet_title">Select Model</string>

View file

@ -63,6 +63,41 @@
<string name="sidebar_earlier">FRÜHER</string>
<string name="sidebar_logout">Abmelden</string>
<string name="sidebar_settings">Einstellungen</string>
<string name="sidebar_rename">Umbenennen</string>
<string name="sidebar_delete">Löschen</string>
<string name="sidebar_pin">Anheften</string>
<string name="sidebar_unpin">Lösen</string>
<string name="sidebar_lock">Sperren</string>
<string name="sidebar_unlock">Entsperren</string>
<string name="sidebar_more">Mehr</string>
<string name="sidebar_rename_title">Chat umbenennen</string>
<string name="sidebar_delete_confirm">Chat endgültig löschen?</string>
<string name="sidebar_cancel">Abbrechen</string>
<string name="sidebar_confirm">Bestätigen</string>
<string name="sidebar_save">Speichern</string>
<!-- Reasoning presets -->
<string name="reasoning_instant">Sofort</string>
<string name="reasoning_none">Aus</string>
<string name="reasoning_minimal">Minimal</string>
<string name="reasoning_low">Niedrig</string>
<string name="reasoning_standard">Standard</string>
<string name="reasoning_medium">Mittel</string>
<string name="reasoning_high">Hoch</string>
<string name="reasoning_xhigh">Maximal</string>
<!-- Sampling -->
<string name="sampling_temperature">Temperatur</string>
<string name="sampling_top_p">Top-P</string>
<string name="sampling_top_k">Top-K</string>
<!-- Biometric unlock -->
<string name="biometric_title">Chat entsperren</string>
<string name="biometric_subtitle">Bestätige deine Identität</string>
<string name="biometric_cancel">Abbrechen</string>
<string name="biometric_not_available">Biometrie nicht verfügbar</string>
<string name="biometric_failed">Authentifizierung fehlgeschlagen</string>
<string name="unlock_tap_to_unlock">Tippen zum Entsperren</string>
<!-- Model sheet -->
<string name="model_sheet_title">Modell auswählen</string>

View file

@ -2,6 +2,7 @@ package dev.kaizen.app
import dev.kaizen.app.db.Converters
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@ -61,4 +62,56 @@ class ConvertersTest {
assertEquals(1, result.size)
assertEquals("https://x.com/f", result[0].url)
}
// --- SearchSource converter tests ---
@Test
fun emptySourcesRoundTrip() {
val json = converters.sourcesToJson(emptyList())
val result = converters.jsonToSources(json)
assertTrue(result.isEmpty())
}
@Test
fun singleSourceRoundTrip() {
val original = listOf(
SearchSource(title = "Wikipedia", url = "https://en.wikipedia.org/wiki/Test", snippet = "A test article")
)
val json = converters.sourcesToJson(original)
val result = converters.jsonToSources(json)
assertEquals(1, result.size)
assertEquals("Wikipedia", result[0].title)
assertEquals("https://en.wikipedia.org/wiki/Test", result[0].url)
assertEquals("A test article", result[0].snippet)
}
@Test
fun multipleSourcesRoundTrip() {
val original = listOf(
SearchSource(title = "Source 1", url = "https://a.com"),
SearchSource(title = "Source 2", url = "https://b.com", snippet = "Details"),
)
val json = converters.sourcesToJson(original)
val result = converters.jsonToSources(json)
assertEquals(2, result.size)
assertEquals("Source 1", result[0].title)
assertEquals("Source 2", result[1].title)
assertEquals("Details", result[1].snippet)
}
@Test
fun malformedSourcesJsonReturnsEmptyList() {
val result = converters.jsonToSources("{broken")
assertTrue(result.isEmpty())
}
@Test
fun sourcesWithUnknownFieldsIgnored() {
val json = """[{"title":"T","url":"https://x.com","snippet":"","futureField":"v"}]"""
val result = converters.jsonToSources(json)
assertEquals(1, result.size)
assertEquals("T", result[0].title)
}
}

View file

@ -7,8 +7,10 @@ import dev.kaizen.app.db.toStoredMessage
import dev.kaizen.app.db.toSummary
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.SearchSource
import dev.kaizen.app.net.StoredMessage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@ -119,4 +121,64 @@ class MappersTest {
assertEquals(original.attachments[0].mimeType, roundTripped.attachments[0].mimeType)
assertEquals(original.attachments[0].size, roundTripped.attachments[0].size)
}
@Test
fun reasoningPreservedInRoundTrip() {
val original = StoredMessage(
id = "msg-r", role = "assistant", content = "Answer",
reasoning = "Let me think about this step by step...",
)
val roundTripped = original.toEntity("c1", 0).toStoredMessage()
assertEquals("Let me think about this step by step...", roundTripped.reasoning)
}
@Test
fun nullReasoningPreserved() {
val original = StoredMessage(id = "msg-nr", role = "assistant", content = "Simple answer")
val entity = original.toEntity("c1", 0)
assertNull(entity.reasoning)
val roundTripped = entity.toStoredMessage()
assertNull(roundTripped.reasoning)
}
@Test
fun sourcesPreservedInRoundTrip() {
val sources = listOf(
SearchSource(title = "Wikipedia", url = "https://en.wikipedia.org/wiki/Test", snippet = "A test"),
SearchSource(title = "MDN", url = "https://developer.mozilla.org", snippet = "Web docs"),
)
val original = StoredMessage(
id = "msg-s", role = "assistant", content = "Search result",
sources = sources,
)
val roundTripped = original.toEntity("c1", 0).toStoredMessage()
assertEquals(2, roundTripped.sources?.size)
assertEquals("Wikipedia", roundTripped.sources!![0].title)
assertEquals("https://developer.mozilla.org", roundTripped.sources!![1].url)
}
@Test
fun emptySourcesBecomeNull() {
val original = StoredMessage(id = "msg-es", role = "assistant", content = "No sources")
val entity = original.toEntity("c1", 0)
assertTrue(entity.sources.isEmpty())
val roundTripped = entity.toStoredMessage()
assertNull(roundTripped.sources)
}
@Test
fun reasoningAndSourcesTogether() {
val original = StoredMessage(
id = "msg-rs", role = "assistant", content = "Full answer",
reasoning = "Thinking...",
sources = listOf(SearchSource(title = "Src", url = "https://src.com")),
)
val roundTripped = original.toEntity("c1", 0).toStoredMessage()
assertEquals("Thinking...", roundTripped.reasoning)
assertEquals(1, roundTripped.sources?.size)
assertEquals("Src", roundTripped.sources!![0].title)
}
}

View file

@ -13,6 +13,7 @@ okhttp = "4.12.0"
kotlinxSerialization = "1.7.3"
securityCrypto = "1.1.0-alpha06"
room = "2.8.4"
biometric = "1.4.0-alpha02"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -21,6 +22,7 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }