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/`) ### Package structure (`app/src/main/java/dev/kaizen/app/`)
``` ```
auth/ LoginScreen auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject)
chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels, chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels,
ModelSheet, Markdown, Background ModelSheet, Markdown, Background
db/ Room offline cache: KaizenDatabase, Entities, DAOs, 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/KaizenDatabase.kt` — singleton via `companion object` (double-checked locking), thread-safe
- `db/ConversationRepository.kt``observeAll()` (Flow of ConversationSummary), `refresh()` (server → Room), `allUnlockedIds()` - `db/ConversationRepository.kt``observeAll()` (Flow of ConversationSummary), `refresh()` (server → Room), `allUnlockedIds()`
- `db/MessageRepository.kt``observeMessages()`, `getCached()`, `fetchAndCache()`, `cacheMessages()`, `prefetchMissing()` (background prefetch for offline) - `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` - `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)). **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/me`** — identity + `defaultModel` for native clients.
- **`GET /api/v1/models`** — Bearer-authed, returns filtered model catalog. - **`GET /api/v1/models`** — Bearer-authed, returns filtered model catalog.
- **`/api/v1/conversations`** — thin re-exports of canonical handlers, Bearer-authed. - **`/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. - **`GET /api/v1/meta`** — capability handshake.
- **App-devices card** (`components/settings/app-tokens-card.tsx`) — see/revoke signed-in devices on `/settings/security`. - **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. - **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` | | `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()` | | `cad445f` | **i18n cleanup** — replaced all remaining hardcoded German strings with `stringResource()` |
| `5c54b9b` | **ToolEventsBlock rewrite** — state machine matching web frontend (thinking/analyzing/start/end/error) | | `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):** **Earlier UI/feel work (Phase 0 prototype → feel-first):**
- Liquid glass styling, floating panels, glassmorphic sidebar - 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 rename/delete** from the sidebar (currently read-only)
- [ ] **Conversation pin/unpin** from the sidebar - [ ] **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 ### 3. Security hardening
- [ ] **Auto-logout on 401** — if the token is rejected, route to login screen instead of showing error banner - [ ] **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 - [ ] **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 - [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B
### 4. Features for parity with the web ### 4. Features for parity with the web

View file

@ -60,6 +60,8 @@ dependencies {
// Room offline cache (local SQLite read-cache for conversations + messages) // Room offline cache (local SQLite read-cache for conversations + messages)
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler) 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) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) 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 accent = LocalKaizenAccent.current
val base = MaterialTheme.colorScheme.background val base = MaterialTheme.colorScheme.background
// Blobs are subtler in light mode. // 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 transition = rememberInfiniteTransition(label = "blobs")
val drift by transition.animateFloat( val drift by transition.animateFloat(
initialValue = 0f, initialValue = 0f,
@ -65,14 +65,14 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
drawRect(brush = ditherBrush, alpha = DITHER_ALPHA) drawRect(brush = ditherBrush, alpha = DITHER_ALPHA)
}, },
) { ) {
Blob(accent.blob1, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f * factor) 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.24f * factor) Blob(accent.blob2, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.36f * factor)
Blob(accent.blob4, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor) Blob(accent.blob3, 320.dp, (220f + drift * 36f).dp, (80f - drift * 28f).dp, 0.30f * factor)
// Vignette: darken the edges so the center comes forward (dark mode only). Blob(accent.blob4, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.34f * factor)
if (dark) { if (dark) {
Box( Box(
Modifier.fillMaxSize().background( 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) {}, .pointerInput(Unit) {},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// --- LAYER 1: Ambient Outer Halo (Subtle larger glow) --- // --- LAYER 1: Ambient Outer Halo ---
// This is the ONLY layer that is blurred, providing a soft background glow.
Box( Box(
Modifier Modifier
.size(size * 1.5f) .size(size * 1.5f)
@ -147,137 +146,143 @@ fun KaizenOrb(
.background( .background(
Brush.radialGradient( Brush.radialGradient(
colors = if (recording) { colors = if (recording) {
listOf(Color(0xFFE0A23E).copy(alpha = 0.45f), Color.Transparent) listOf(accent.primary.copy(alpha = 0.45f), Color.Transparent)
} else { } 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, CircleShape,
), ),
) )
// --- LAYER 2: Glass Sphere body with refracted backgrounds & conic caustics --- // --- LAYER 2: Glass body — transparent tint so background bleeds through ---
// 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) ---
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.toPx() val w = size.toPx()
val h = size.toPx() val h = size.toPx()
val radius = w / 2f 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 // Base: very subtle theme tint (glass, not paint)
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!
drawCircle( drawCircle(
brush = Brush.radialGradient( brush = Brush.radialGradient(
colors = listOf(Color.White, Color.Transparent), colorStops = arrayOf(
center = specularCenter1, 0.0f to accent.primary.copy(alpha = if (isDark) 0.22f else 0.15f),
radius = radius * 0.38f 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( drawCircle(
brush = Brush.radialGradient( brush = Brush.radialGradient(
colors = listOf( colors = listOf(
Color(0xFFFFD180).copy(alpha = 0.80f), // Bright warm amber light refraction accent.primary.copy(alpha = if (isDark) 0.50f else 0.35f),
Color.Transparent 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( 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, 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.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween 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.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border 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.GlassSurface
import dev.kaizen.app.ui.effect.GlassTiers import dev.kaizen.app.ui.effect.GlassTiers
import dev.kaizen.app.ui.effect.KaizenShadows 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.motion.Durations
import dev.kaizen.app.ui.shape.KaizenShapes import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.ui.theme.LocalKaizenAccent 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.material.icons.rounded.VideoFile
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue 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.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -142,14 +162,24 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
} }
@Composable @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 cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
val glassBorder = remember(isDark) { var showReasoningMenu by remember { mutableStateOf(false) }
if (isDark) Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.14f), Color.White.copy(alpha = 0.04f))) var showSamplingPopup by remember { mutableStateOf(false) }
else Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.50f), Color.Black.copy(alpha = 0.06f)))
} 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( Row(
modifier modifier
@ -158,25 +188,98 @@ fun ModePillsRow(selected: Set<Int>, onToggle: (Int) -> Unit, modifier: Modifier
.padding(horizontal = 14.dp), .padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
chatModes.forEach { mode -> // Reasoning pill (dropdown)
val label = stringResource(mode.labelRes) Box {
val active = mode.labelRes in selected Row(
val backgroundFill = if (active) { Modifier
if (isDark) cs.surface.copy(alpha = 0.8f) else cs.surface.copy(alpha = 0.88f) .clip(KaizenShapes.pill)
} else { .background(inactiveBg)
if (isDark) cs.surface.copy(alpha = 0.35f) else cs.surface.copy(alpha = 0.55f) .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( Row(
Modifier Modifier
.clip(KaizenShapes.pill) .clip(KaizenShapes.pill)
.background(backgroundFill) .background(backgroundFill)
.border(1.2.dp, glassBorder, KaizenShapes.pill) .border(1.2.dp, borderColor, KaizenShapes.pill)
.clickable { onToggle(mode.labelRes) } .clickable { onToggle(mode.labelRes) }
.padding(horizontal = 13.dp, vertical = 8.dp), .padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, 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)) 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) 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 @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 cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current val accent = LocalKaizenAccent.current
val hasAttachments = message.attachments.isNotEmpty() 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) { if (message.role == Role.User) {
Column(horizontalAlignment = Alignment.End, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
val shape = KaizenShapes.lg 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, isDark) {
val bgBrush = remember(accent) { Brush.verticalGradient(listOf(accent.primary.copy(alpha = 0.18f), accent.primaryDeep.copy(alpha = 0.08f))) } 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( Box(
Modifier Modifier
.widthIn(max = 300.dp) .widthIn(max = 300.dp)
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 20.dp)
.clip(shape) .clip(shape)
.background(bgBrush) .background(bgBrush)
.background(innerHighlight)
.border(1.2.dp, borderBrush, shape), .border(1.2.dp, borderBrush, shape),
) { ) {
Column { Column {
if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp)) if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp))
if (message.content.isNotBlank()) { if (message.content.isNotBlank()) {
Text( Text(
message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp, 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 11.dp, bottom = 11.dp), 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 { } else {
Column {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
KaizenOrb(28.dp, streaming = message.streaming) KaizenOrb(28.dp, streaming = message.streaming)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Box(Modifier.weight(1f).padding(top = 3.dp)) { Box(Modifier.weight(1f).padding(top = 3.dp)) {
Column { Column {
if (message.reasoning.isNotBlank()) {
ReasoningBlock(message.reasoning, streaming = message.streaming)
Spacer(Modifier.height(8.dp))
}
if (message.tools.isNotEmpty()) { if (message.tools.isNotEmpty()) {
ToolEventsBlock(message.tools, streaming = message.streaming) ToolEventsBlock(message.tools, streaming = message.streaming)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -231,10 +456,6 @@ fun MessageRow(message: Message) {
} }
if (message.thinking) TypingDots() if (message.thinking) TypingDots()
else MarkdownText(text = message.content, streaming = message.streaming) 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()) { if (message.sources.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
SourcesBlock(message.sources, query = message.query) 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 cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val hasContent = value.isNotBlank() || pendingFiles.any { it.uploaded != null }
val canSend = enabled && hasContent && pendingFiles.none { it.uploading }
GlassSurface( GlassSurface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp), .padding(horizontal = 8.dp),
shape = KaizenShapes.xl, shape = KaizenShapes.xl,
tintAlpha = GlassTiers.input(isDark), tintAlpha = GlassTiers.input(isDark),
shadowStack = KaizenShadows.level2, shadowStack = KaizenShadows.level2,
@ -300,39 +567,35 @@ fun ChatInput(
Column { Column {
if (pendingFiles.isNotEmpty()) { if (pendingFiles.isNotEmpty()) {
Row( 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), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) } pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
} }
} }
Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(start = 6.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), verticalAlignment = Alignment.Bottom) {
Box(Modifier.size(40.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) { 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)) 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) { 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, fontSize = 16.sp) if (value.isEmpty()) Text(stringResource(R.string.chat_input_placeholder), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 15.sp)
val accent = LocalKaizenAccent.current val accent = LocalKaizenAccent.current
BasicTextField( BasicTextField(
value = value, onValueChange = onValueChange, 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(), cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(),
) )
} }
GhostIconButton(Icons.Rounded.Call, stringResource(R.string.chat_call))
Spacer(Modifier.width(4.dp)) 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) 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 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( data class Message(
val id: Long, val id: Long,
val role: Role, 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons 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.material.icons.rounded.Menu
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -96,7 +99,10 @@ fun ChatScreen(
var input by remember { mutableStateOf("") } var input by remember { mutableStateOf("") }
var isStreaming by remember { mutableStateOf(false) } var isStreaming by remember { mutableStateOf(false) }
var nextId by remember { mutableStateOf(0L) } 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 // Navigation State
var currentScreen by remember { mutableStateOf(AppScreen.Chat) } var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
@ -117,6 +123,7 @@ fun ChatScreen(
// Conversation persistence: sidebar list from Room (instant), active conversation id // Conversation persistence: sidebar list from Room (instant), active conversation id
var conversationId by remember { mutableStateOf<String?>(null) } var conversationId by remember { mutableStateOf<String?>(null) }
var viewingLockedChat by remember { mutableStateOf(false) }
val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList()) val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList())
var loadError by remember { mutableStateOf<String?>(null) } var loadError by remember { mutableStateOf<String?>(null) }
@ -194,7 +201,12 @@ fun ChatScreen(
session.config?.let { cfg -> session.config?.let { cfg ->
scope.launch { KaizenApi.prewarm(cfg.baseUrl) } scope.launch { KaizenApi.prewarm(cfg.baseUrl) }
} }
onPauseOrDispose { } onPauseOrDispose {
if (viewingLockedChat) {
messages.clear()
viewingLockedChat = false
}
}
} }
fun refreshConversations() { fun refreshConversations() {
@ -206,14 +218,58 @@ fun ChatScreen(
fun newChat() { fun newChat() {
messages.clear() messages.clear()
conversationId = null conversationId = null
viewingLockedChat = false
} }
fun openConversation(summary: ConversationSummary) { fun openConversation(summary: ConversationSummary) {
if (summary.locked) return
val cfg = session.config ?: 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 { scope.launch {
// Instant: load from Room cache first
val cached = chat.messageRepo.observeMessages(summary.id)
messages.clear() messages.clear()
val cachedList = chat.messageRepo.getCached(summary.id) val cachedList = chat.messageRepo.getCached(summary.id)
cachedList.forEach { m -> cachedList.forEach { m ->
@ -224,16 +280,16 @@ fun ChatScreen(
content = m.content, content = m.content,
wireId = m.id, wireId = m.id,
attachments = m.attachments, attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
), ),
) )
} }
conversationId = summary.id conversationId = summary.id
// Background: refresh from server and update Room + UI
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id) val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
if (stored.isNotEmpty()) { if (stored.isNotEmpty()) {
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) } val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
chat.messageRepo.cacheMessages(summary.id, entities) 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 }) { if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) {
messages.clear() messages.clear()
stored.forEach { m -> stored.forEach { m ->
@ -244,6 +300,8 @@ fun ChatScreen(
content = m.content, content = m.content,
wireId = m.id, wireId = m.id,
attachments = m.attachments, attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
), ),
) )
} }
@ -284,8 +342,14 @@ fun ChatScreen(
} else null } else null
KaizenApi.chat( 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() }, 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 -> ).collect { state ->
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect if (idx < 0) return@collect
@ -301,9 +365,15 @@ fun ChatScreen(
query = state.query, query = state.query,
sources = state.sources, sources = state.sources,
) )
if (state.inputTokens > 0 || state.outputTokens > 0) {
usedTokens = state.inputTokens + state.outputTokens
}
} }
val idx = messages.indexOfLast { it.id == assistantId } 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) if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId
@ -317,11 +387,15 @@ fun ChatScreen(
role = "user", content = trimmed, role = "user", content = trimmed,
attachments = uploadedAtts.takeIf { it.isNotEmpty() }, 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) { if (saved) {
// Cache new messages in Room for offline-first
val msgCount = messages.size val msgCount = messages.size
chat.messageRepo.cacheMessages(convId, listOf( chat.messageRepo.cacheMessages(convId, listOf(
MessageEntity( MessageEntity(
@ -333,6 +407,8 @@ fun ChatScreen(
id = assistantWireId, conversationId = convId, id = assistantWireId, conversationId = convId,
role = "assistant", content = finalContent, role = "assistant", content = finalContent,
attachments = emptyList(), sortOrder = msgCount - 1, attachments = emptyList(), sortOrder = msgCount - 1,
reasoning = finalReasoning,
sources = finalSources ?: emptyList(),
), ),
)) ))
if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId) if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
@ -409,7 +485,32 @@ fun ChatScreen(
newChat() newChat()
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
session.logout() 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 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), 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 }, onClick = { haptics.tick(); showModelSheet = true },
modifier = Modifier.weight(1f, fill = false), 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() .navigationBarsPadding()
.imePadding()
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier) .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
) { ) {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
ModePillsRow( ModePillsRow(
selected = selectedModes, activeMode = activeMode,
onToggle = { mode -> onToggle = { mode ->
selectedModes = if (mode in selectedModes) selectedModes - mode else selectedModes + mode activeMode = if (activeMode == mode) null else mode
haptics.tick() haptics.tick()
}, },
reasoningPreset = reasoningPreset,
onReasoningChange = { reasoningPreset = it; haptics.tick() },
samplingPrefs = samplingPrefs,
onSamplingChange = { samplingPrefs = it },
) )
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
ChatInput( ChatInput(

View file

@ -363,20 +363,20 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
val isLast = idx == blocks.lastIndex val isLast = idx == blocks.lastIndex
when (block) { when (block) {
is MdBlock.Heading -> { 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) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg)
Text( Text(
text = rendered, text = rendered,
color = cs.onBackground, color = cs.onBackground,
fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp }, fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp },
fontWeight = FontWeight.Bold, 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 -> { is MdBlock.CodeFence -> {
if (idx > 0) Spacer(Modifier.height(6.dp)) if (idx > 0) Spacer(Modifier.height(12.dp))
Box( Box(
Modifier Modifier
.fillMaxWidth() .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 -> { is MdBlock.ListBlock -> {
if (idx > 0) Spacer(Modifier.height(4.dp)) if (idx > 0) Spacer(Modifier.height(8.dp))
block.items.forEachIndexed { itemIdx, item -> 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 "" val bullet = if (item.ordered) "${item.index}." else ""
Text( Text(
bullet, 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), text = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground, color = cs.onBackground,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 23.sp, lineHeight = 26.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(8.dp))
} }
is MdBlock.HorizontalRule -> { is MdBlock.HorizontalRule -> {
if (idx > 0) Spacer(Modifier.height(8.dp)) if (idx > 0) Spacer(Modifier.height(14.dp))
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(1.dp) .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 -> { is MdBlock.Blockquote -> {
if (idx > 0) Spacer(Modifier.height(6.dp)) if (idx > 0) Spacer(Modifier.height(12.dp))
Row( Row(
Modifier Modifier
.fillMaxWidth() .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), text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg),
color = cs.onSurfaceVariant, color = cs.onSurfaceVariant,
fontSize = 15.sp, fontSize = 15.sp,
lineHeight = 22.sp, lineHeight = 24.sp,
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(12.dp))
} }
is MdBlock.Paragraph -> { is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(6.dp)) if (idx > 0) Spacer(Modifier.height(12.dp))
Text( Text(
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg), text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground, color = cs.onBackground,
fontSize = 16.sp, 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.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.automirrored.rounded.Logout 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.Edit
import androidx.compose.material.icons.rounded.Lock 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.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Star 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.FontStyle
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.ConversationSummary import dev.kaizen.app.net.ConversationSummary
@ -76,6 +94,13 @@ private fun groupConversations(conversations: List<ConversationSummary>, labels:
return groups 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 @Composable
fun KaizenSidebar( fun KaizenSidebar(
userName: String, userName: String,
@ -86,18 +111,23 @@ fun KaizenSidebar(
onSelectConversation: (ConversationSummary) -> Unit, onSelectConversation: (ConversationSummary) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onAction: (SidebarAction) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
var deleteTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameText by remember { mutableStateOf("") }
GlassSurface( GlassSurface(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.width(300.dp) .width(300.dp)
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp), .padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
shape = KaizenShapes.lg, shape = KaizenShapes.lg,
tintAlpha = GlassTiers.sidebar(isDark), tintAlpha = if (isDark) 0.88f else 0.92f,
shadowStack = KaizenShadows.level3, shadowStack = KaizenShadows.level3,
cornerRadius = 24.dp, cornerRadius = 24.dp,
) { ) {
@ -113,46 +143,50 @@ fun KaizenSidebar(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically 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( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(34.dp)
.clip(KaizenShapes.circle) .clip(KaizenShapes.circle)
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000)) .background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
.clickable { onNewChat() }, .clickable { onNewChat() },
contentAlignment = Alignment.Center 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 // Search
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.sm) .clip(KaizenShapes.pill)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000)) .background(if (isDark) Color(0x0DFFFFFF) else Color(0x08000000))
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.sm) .padding(horizontal = 14.dp, vertical = 9.dp),
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically 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)) 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 // Conversation list
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
if (conversations.isEmpty()) { if (conversations.isEmpty()) {
Text( Text(
stringResource(R.string.chat_no_conversations), stringResource(R.string.chat_no_conversations),
color = cs.onSurfaceVariant.copy(alpha = 0.5f), color = cs.onSurfaceVariant.copy(alpha = 0.4f),
fontSize = 14.sp, fontSize = 13.sp,
modifier = Modifier.padding(start = 12.dp, top = 12.dp) modifier = Modifier.padding(start = 8.dp, top = 8.dp)
) )
} else { } else {
val labels = DateLabels( val labels = DateLabels(
@ -164,16 +198,16 @@ fun KaizenSidebar(
val grouped = remember(conversations, labels) { groupConversations(conversations, labels) } val grouped = remember(conversations, labels) { groupConversations(conversations, labels) }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
grouped.forEach { (dateGroup, items) -> grouped.forEach { (dateGroup, items) ->
item(key = "header_$dateGroup") { item(key = "header_$dateGroup") {
Text( Text(
dateGroup, dateGroup,
color = cs.onSurfaceVariant.copy(alpha = 0.45f), color = cs.onSurfaceVariant.copy(alpha = 0.4f),
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp) modifier = Modifier.padding(start = 8.dp, top = 14.dp, bottom = 6.dp)
) )
} }
items(items, key = { it.id }) { item -> items(items, key = { it.id }) { item ->
@ -181,6 +215,13 @@ fun KaizenSidebar(
item = item, item = item,
selected = item.id == currentId, selected = item.id == currentId,
onClick = { onSelectConversation(item) }, 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.md) .clip(KaizenShapes.md)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000)) .background(if (isDark) Color(0x0DFFFFFF) else Color(0x06000000))
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.md)
.clickable { onOpenSettings() } .clickable { onOpenSettings() }
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(30.dp)
.clip(KaizenShapes.circle) .clip(KaizenShapes.circle)
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)), .background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
userName.firstOrNull()?.toString()?.uppercase() ?: "A", 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)) Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) { Text(
Text(userName, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) 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 // Rename dialog
Row( 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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.sm) .clip(KaizenShapes.sm)
.clickable { onLogout() } .background(if (isDark) Color(0x12FFFFFF) else Color(0x08000000))
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 14.dp, vertical = 12.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)) confirmButton = {
Spacer(Modifier.width(10.dp)) TextButton(onClick = {
Text(stringResource(R.string.sidebar_logout), color = cs.onSurfaceVariant.copy(alpha = 0.85f), fontSize = 14.sp, fontWeight = FontWeight.Medium) 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 @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 cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current val accent = LocalKaizenAccent.current
var showMenu by remember { mutableStateOf(false) }
val itemBg = when { val itemBg = when {
selected -> accent.primary.copy(alpha = 0.14f) selected -> accent.primary.copy(alpha = if (isDark) 0.14f else 0.10f)
isDark -> Color(0x0AFFFFFF) else -> Color.Transparent
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)
} }
Box {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.pill) .clip(KaizenShapes.sm)
.background(itemBg) .background(itemBg)
.border(1.2.dp, itemBorder, KaizenShapes.pill)
.clickable { onClick() } .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 verticalAlignment = Alignment.CenterVertically
) { ) {
if (item.pinned) { if (item.pinned) {
Icon(Icons.Rounded.Star, null, tint = accent.primary, modifier = Modifier.size(14.dp)) Icon(Icons.Rounded.Star, null, tint = accent.primary.copy(alpha = 0.7f), modifier = Modifier.size(13.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(6.dp))
} }
Text( Text(
text = if (item.locked) stringResource(R.string.chat_locked) else item.title, 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, color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.45f) else cs.onBackground.copy(alpha = 0.85f),
fontSize = 14.sp, fontSize = 13.5.sp,
fontWeight = FontWeight.Medium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.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) { if (item.locked) {
Spacer(Modifier.width(8.dp)) Icon(Icons.Rounded.Lock, null, tint = accent.primary.copy(alpha = 0.5f), modifier = Modifier.size(13.dp))
Icon(Icons.Rounded.Lock, stringResource(R.string.chat_locked_badge), tint = accent.primary, modifier = Modifier.size(15.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)) Icon(Icons.Rounded.Psychology, null, tint = accent.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
"Gedankengang", stringResource(R.string.stream_reasoning),
color = cs.onSurfaceVariant, color = cs.onSurfaceVariant,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,

View file

@ -2,6 +2,7 @@ package dev.kaizen.app.db
import androidx.room.TypeConverter import androidx.room.TypeConverter
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -15,4 +16,12 @@ class Converters {
@TypeConverter @TypeConverter
fun jsonToAttachments(value: String): List<Attachment> = fun jsonToAttachments(value: String): List<Attachment> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() } 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.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
@Entity(tableName = "conversations") @Entity(tableName = "conversations")
data class ConversationEntity( data class ConversationEntity(
@ -33,4 +34,6 @@ data class MessageEntity(
val content: String, val content: String,
val attachments: List<Attachment>, val attachments: List<Attachment>,
val sortOrder: Int, 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, content = content,
attachments = attachments, attachments = attachments,
sortOrder = sortOrder, sortOrder = sortOrder,
reasoning = reasoning,
sources = sources ?: emptyList(),
) )
fun MessageEntity.toStoredMessage() = StoredMessage( fun MessageEntity.toStoredMessage() = StoredMessage(
@ -34,4 +36,6 @@ fun MessageEntity.toStoredMessage() = StoredMessage(
role = role, role = role,
content = content, content = content,
attachments = attachments, 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 reasoningEffort: String? = null,
val preferThroughput: Boolean? = null, val preferThroughput: Boolean? = null,
val attachments: List<Attachment>? = 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. */ /** 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 id: String,
val name: String? = null, val name: String? = null,
val provider: String? = null, val provider: String? = null,
val context_length: Int? = null,
) )
@Serializable private data class ModelsResponse(val models: List<KaizenModel> = emptyList()) @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 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 CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String) @Serializable private data class CreatedConversation(val id: String)
@ -79,6 +85,8 @@ data class StoredMessage(
val role: String, val role: String,
val content: String = "", val content: String = "",
val attachments: List<Attachment> = emptyList(), val attachments: List<Attachment> = emptyList(),
val reasoning: String? = null,
val sources: List<SearchSource>? = null,
) )
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList()) @Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
@ -93,6 +101,8 @@ data class SaveMessage(
val kind: String = "chat", val kind: String = "chat",
val model: String? = null, val model: String? = null,
val attachments: List<Attachment>? = null, val attachments: List<Attachment>? = null,
val reasoning: String? = null,
val sources: List<SearchSource>? = null,
) )
@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>) @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. */ /** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
suspend fun generateTitle(baseUrl: String, token: String, id: String) = suspend fun generateTitle(baseUrl: String, token: String, id: String) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -348,16 +460,25 @@ object KaizenApi {
token: String, token: String,
model: String, model: String,
messages: List<WireMessage>, messages: List<WireMessage>,
speed: ChatSpeed = ChatSpeed.Normal, reasoningEffort: String? = null,
preferThroughput: Boolean = false,
attachments: List<Attachment>? = null, attachments: List<Attachment>? = null,
webSearch: Boolean = false,
temperature: Float? = null,
topP: Float? = null,
topK: Int? = null,
): Flow<StreamState> = flow { ): Flow<StreamState> = flow {
val payload = json.encodeToString( val payload = json.encodeToString(
ChatRequest( ChatRequest(
model = model, model = model,
messages = messages, messages = messages,
reasoningEffort = speed.reasoningEffort, reasoningEffort = reasoningEffort,
preferThroughput = if (speed.preferThroughput) true else null, preferThroughput = if (preferThroughput) true else null,
attachments = attachments?.takeIf { it.isNotEmpty() }, attachments = attachments?.takeIf { it.isNotEmpty() },
webSearch = if (webSearch) true else null,
temperature = temperature,
topP = topP,
topK = topK,
), ),
).toRequestBody(jsonMedia) ).toRequestBody(jsonMedia)
val req = Request.Builder() val req = Request.Builder()

View file

@ -28,12 +28,20 @@ data class SearchSource(
val snippet: String = "", val snippet: String = "",
) )
@Serializable
private data class UsageJson(
val i: Int = 0,
val o: Int = 0,
)
data class StreamState( data class StreamState(
val content: String = "", val content: String = "",
val reasoning: String = "", val reasoning: String = "",
val tools: List<ToolEvent> = emptyList(), val tools: List<ToolEvent> = emptyList(),
val query: String = "", val query: String = "",
val sources: List<SearchSource> = emptyList(), val sources: List<SearchSource> = emptyList(),
val inputTokens: Int = 0,
val outputTokens: Int = 0,
) )
object StreamConsumer { object StreamConsumer {
@ -101,12 +109,15 @@ object StreamConsumer {
private fun buildState(): StreamState { private fun buildState(): StreamState {
val queryStr = parseTrailingField(QUERY) val queryStr = parseTrailingField(QUERY)
val sourcesList = parseTrailingSources() val sourcesList = parseTrailingSources()
val usage = parseTrailingUsage()
return StreamState( return StreamState(
content = content.toString(), content = content.toString(),
reasoning = reasoningBuf.toString(), reasoning = reasoningBuf.toString(),
tools = tools.toList(), tools = tools.toList(),
query = queryStr, query = queryStr,
sources = sourcesList, 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) { private fun parseToolEvents(buf: CharSequence, from: Int, to: Int) {
val s = buf.toString() val s = buf.toString()
var pos = from var pos = from

View file

@ -92,7 +92,7 @@ fun GlassSurface(
* Use these constants with [GlassSurface] instead of ad-hoc alpha values. * Use these constants with [GlassSurface] instead of ad-hoc alpha values.
*/ */
object GlassTiers { 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 input(isDark: Boolean) = if (isDark) 0.75f else 0.74f
fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f
fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f 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_earlier">EARLIER</string>
<string name="sidebar_logout">Sign Out</string> <string name="sidebar_logout">Sign Out</string>
<string name="sidebar_settings">Settings</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 --> <!-- Model sheet -->
<string name="model_sheet_title">Select Model</string> <string name="model_sheet_title">Select Model</string>

View file

@ -63,6 +63,41 @@
<string name="sidebar_earlier">FRÜHER</string> <string name="sidebar_earlier">FRÜHER</string>
<string name="sidebar_logout">Abmelden</string> <string name="sidebar_logout">Abmelden</string>
<string name="sidebar_settings">Einstellungen</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 --> <!-- Model sheet -->
<string name="model_sheet_title">Modell auswählen</string> <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.db.Converters
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -61,4 +62,56 @@ class ConvertersTest {
assertEquals(1, result.size) assertEquals(1, result.size)
assertEquals("https://x.com/f", result[0].url) 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.db.toSummary
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ConversationSummary import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.SearchSource
import dev.kaizen.app.net.StoredMessage import dev.kaizen.app.net.StoredMessage
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -119,4 +121,64 @@ class MappersTest {
assertEquals(original.attachments[0].mimeType, roundTripped.attachments[0].mimeType) assertEquals(original.attachments[0].mimeType, roundTripped.attachments[0].mimeType)
assertEquals(original.attachments[0].size, roundTripped.attachments[0].size) 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" kotlinxSerialization = "1.7.3"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.1.0-alpha06"
room = "2.8.4" room = "2.8.4"
biometric = "1.4.0-alpha02"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-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-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }