Compare commits

..

No commits in common. "34a527cdb646c08cd8f3365799584b5379facac8" and "ad11c0ee46f7f0d6d5b93dd0889cda5316a40ce4" have entirely different histories.

2 changed files with 25 additions and 62 deletions

View file

@ -228,7 +228,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) |
| `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 uses `PickMultipleVisualMedia(ImageAndVideo)`, file uses `OpenMultipleDocuments` — gallery + files support multi-select | | `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 `*/*` |
| `f3d8154` | **Sidebar redesign** — GlassSurface opacity 0.92/0.88, flat conversation rows, 3-dot context menu (rename/delete/pin/lock via `PATCH`/`DELETE /api/v1/conversations`), smaller avatar (30dp), logout integrated into user card | | `f3d8154` | **Sidebar redesign** — GlassSurface opacity 0.92/0.88, flat conversation rows, 3-dot context menu (rename/delete/pin/lock via `PATCH`/`DELETE /api/v1/conversations`), smaller avatar (30dp), logout integrated into user card |
| `f3d8154` | **Markdown typography overhaul** — line-height 23→26sp (×1.625), paragraph spacing 6→12dp, heading margins 18dp/8dp, list spacing doubled. Matches Gemini readability | | `f3d8154` | **Markdown typography overhaul** — line-height 23→26sp (×1.625), paragraph spacing 6→12dp, heading margins 18dp/8dp, list spacing doubled. Matches Gemini readability |
| `532cb8c` | **Reasoning + sources persistence** — saved to server (`SaveMessage.reasoning/sources`), loaded back (`StoredMessage`), cached in Room (`MessageEntity` + `SearchSource` TypeConverter). Reasoning rendered above content, sources below | | `532cb8c` | **Reasoning + sources persistence** — saved to server (`SaveMessage.reasoning/sources`), loaded back (`StoredMessage`), cached in Room (`MessageEntity` + `SearchSource` TypeConverter). Reasoning rendered above content, sources below |
@ -244,13 +244,6 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
| `2d57aff` | **Input field redesign** — pill shape, higher opacity (0.85/0.90), Mic/Send toggle (mic when empty, send when text), bottom-aligned buttons | | `2d57aff` | **Input field redesign** — pill shape, higher opacity (0.85/0.90), Mic/Send toggle (mic when empty, send when text), bottom-aligned buttons |
| `e9e2a66` | **Stronger haptics** — send CLICK 1.0, thinkingStart double-tap, responseStart crescendo, responseEnd firm settle | | `e9e2a66` | **Stronger haptics** — send CLICK 1.0, thinkingStart double-tap, responseStart crescendo, responseEnd firm settle |
| `e9e2a66` | **Live language switching**`LocaleManager` (API 33+) or `recreate()` fallback, immediate effect | | `e9e2a66` | **Live language switching**`LocaleManager` (API 33+) or `recreate()` fallback, immediate effect |
| `15f5781` | **Biometric bypass fix** — closed 3 auth bypass paths in `openConversation()` + sidebar `ToggleLock`. Unified `requestBiometricOrPassword()` gate, `PasswordUnlockDialog` fallback, server `POST /api/v1/auth/unlock` now verifies password (Argon2id) when provided |
| `ba7ebaf` | **Security settings** — biometric toggle (persisted in SecureStore), signed-in devices list with remote revoke (`GET/DELETE /api/v1/auth/tokens`), password change form (`POST /api/v1/auth/change-password`) |
| `e62c525` | **App icon redesign** — 10-layer glass-lens orb (soft halo, 6-stop amber body, cool glass overlay, bounce light, volume shadow, caustic structure, elliptical specular + micro pinpoint glint, gradient rims). Improved backgrounds (light/dark) + monochrome |
| `1929073` | **Unified top bar** — merged hamburger circle + ModelPill into single GlassSurface pill (menu icon → dot divider → model name 13sp Medium, 180dp max ellipsis). No chevron |
| `1362dae` | **Snappy sidebar** — optimistic insert (new chat appears instantly with first-line preview), `generateTitle` returns title directly + Room `updateTitle`, `replaceAll` uses upsert+deleteExcept (no empty-list flash) |
| `68721d8` | **KaizenIcons system** — centralized icon registry (`ui/icon/KaizenIcons.kt`), Lucide 1.5px stroke as base (same as web), custom override layer for brand icons. All 10 source files migrated, zero Material Icon imports remaining |
| `ad11c0e` | **Multi-select file picker** — gallery uses `PickMultipleVisualMedia(ImageAndVideo)`, file picker uses `OpenMultipleDocuments`. Multiple items selectable at once |
**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
@ -281,14 +274,6 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
- **Token counter always 0** — only updated during streaming, not for loaded chats. Fix: sum `inputTokens + outputTokens` from server messages. - **Token counter always 0** — only updated during streaming, not for loaded chats. Fix: sum `inputTokens + outputTokens` from server messages.
- **Model pill wrong** — showed global default instead of per-chat model. Fix: `chatModel` state from last assistant message's `model` field. - **Model pill wrong** — showed global default instead of per-chat model. Fix: `chatModel` state from last assistant message's `model` field.
### Bugs fixed (2026-06-22 — security + UI session)
- **Locked chats bypassed biometric auth** — three paths in `openConversation()` called `unlockAndOpen()` without authentication when biometrics unavailable. Sidebar `ToggleLock` sent `PATCH { locked: false }` without auth. Fix: unified `requestBiometricOrPassword()` gates all unlock paths; `PasswordUnlockDialog` fallback; server verifies password via Argon2id.
- **New chats not appearing in sidebar** — conversation only showed after stream + save + title + server refresh (seconds of delay). Fix: optimistic Room insert immediately after `createConversation()` with first-line preview as title.
- **Title generation not working immediately**`generateTitle` was fire-and-forget, `refreshConversations()` raced with server-side generation. Fix: `generateTitle` now returns the title from the response; `updateTitle()` writes directly to Room.
- **Sidebar flicker on refresh**`replaceAll()` did `DELETE ALL` + `INSERT`, causing Room Flow to emit empty list briefly. Fix: `upsert` + `deleteExcept` (no flash).
- **Gallery/file picker single-select only**`GetContent()` allowed only one item. Fix: gallery → `PickMultipleVisualMedia(ImageAndVideo)`, file → `OpenMultipleDocuments(["*/*"])`.
## What's NOT done yet (app, priority order) ## What's NOT done yet (app, priority order)
### 1. Remaining visual/UX gaps (daily-drive blockers) ### 1. Remaining visual/UX gaps (daily-drive blockers)
@ -311,15 +296,12 @@ These are what separate "prototype" from "daily-driver":
- [ ] **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
- [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — hardware-enforced, password fallback dialog when unavailable, biometric toggle in Settings, all unlock paths gated - [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — implemented for conversation unlock; `setUserAuthenticationRequired(true)` + `setInvalidatedByBiometricEnrollment(true)` ensures hardware-enforced biometric, not bypassable even on rooted devices
- [x] **Security settings** — biometric toggle, signed-in devices with remote revoke, password change
- [x] **Password change**`POST /api/v1/auth/change-password` (Argon2id, rate-limited)
- [x] **Device management**`GET/DELETE /api/v1/auth/tokens` (list + revoke, can't revoke self)
- [ ] **`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
- [x] **Attachments** — image/file upload (multi-select for gallery + files), attachment preview in messages, display in loaded conversations, camera capture, attachment picker menu (camera/gallery/file) - [x] **Attachments** — image/file upload, attachment preview in messages, display in loaded conversations, camera capture, attachment picker menu (camera/gallery/file)
- [x] **Reasoning/thinking display**`ReasoningBlock` (collapsible), parses `\x01` sentinel - [x] **Reasoning/thinking display**`ReasoningBlock` (collapsible), parses `\x01` sentinel
- [x] **Web search sources**`SourcesBlock` (collapsible, clickable links via ACTION_VIEW), parses `\x02`/`\x04` sentinels. `webSearch: true` in ChatRequest triggers agentive search - [x] **Web search sources**`SourcesBlock` (collapsible, clickable links via ACTION_VIEW), parses `\x02`/`\x04` sentinels. `webSearch: true` in ChatRequest triggers agentive search
- [x] **Tool call display**`ToolEventsBlock`, parses `\x03` sentinel, status icons per step - [x] **Tool call display**`ToolEventsBlock`, parses `\x03` sentinel, status icons per step
@ -356,7 +338,6 @@ okhttp = "4.12.0"
kotlinxSerialization = "1.7.3" kotlinxSerialization = "1.7.3"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.1.0-alpha06"
biometric = "1.4.0-alpha02" biometric = "1.4.0-alpha02"
composeIcons = "1.0.0" # com.composables:icons-lucide-android (Lucide for Compose)
``` ```
## Tests ## Tests

View file

@ -532,66 +532,48 @@ fun ChatInput(
) { ) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
val hasContent = value.isNotBlank() || pendingFiles.any { it.uploaded != null } val hasContent = value.isNotBlank() || pendingFiles.any { it.uploaded != null }
val canSend = enabled && hasContent && pendingFiles.none { it.uploading } val canSend = enabled && hasContent && pendingFiles.none { it.uploading }
val iconBg = if (isDark) Color.White.copy(alpha = 0.07f) else Color.Black.copy(alpha = 0.05f)
GlassSurface( GlassSurface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 8.dp),
shape = KaizenShapes.pill, shape = KaizenShapes.pill,
tintAlpha = if (isDark) 0.82f else 0.88f, tintAlpha = if (isDark) 0.85f else 0.90f,
shadowStack = KaizenShadows.level3, shadowStack = KaizenShadows.level2,
cornerRadius = 32.dp, cornerRadius = 28.dp,
) { ) {
Column(Modifier.padding(horizontal = 14.dp, vertical = 14.dp)) { Column {
if (pendingFiles.isNotEmpty()) { if (pendingFiles.isNotEmpty()) {
Row( Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 10.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(start = 6.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), verticalAlignment = Alignment.Bottom) {
Box(Modifier.fillMaxWidth().heightIn(min = 48.dp), contentAlignment = Alignment.CenterStart) { Box(Modifier.size(42.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) {
if (value.isEmpty()) Text( Icon(KaizenIcons.Plus, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
stringResource(R.string.chat_input_placeholder),
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 16.sp,
)
BasicTextField(
value = value, onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp),
cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(40.dp).clip(KaizenShapes.circle).background(iconBg).clickable(onClick = onAddClick),
contentAlignment = Alignment.Center,
) {
Icon(KaizenIcons.Plus, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(20.dp))
} }
Spacer(Modifier.width(8.dp)) Box(Modifier.weight(1f).heightIn(min = 42.dp).padding(horizontal = 6.dp), contentAlignment = Alignment.CenterStart) {
Box( if (value.isEmpty()) Text(stringResource(R.string.chat_input_placeholder), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 15.sp)
Modifier.size(40.dp).clip(KaizenShapes.circle).background(iconBg), val accent = LocalKaizenAccent.current
contentAlignment = Alignment.Center, BasicTextField(
) { value = value, onValueChange = onValueChange,
Icon(KaizenIcons.Mic, stringResource(R.string.chat_call), tint = cs.onSurfaceVariant, modifier = Modifier.size(20.dp)) textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp),
cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(),
)
} }
Spacer(Modifier.width(4.dp))
Spacer(Modifier.weight(1f))
if (hasContent) { 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(KaizenIcons.Mic, stringResource(R.string.chat_call), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
}
} }
} }
} }