adjustResize didn't work with enableEdgeToEdge (input hidden
behind keyboard). imePadding inflated the dock (input too high).
New approach: read WindowInsets.ime bottom value directly and
apply it as negative Y offset on the dock. The dock slides up
by exactly the keyboard height — no inflation, no layout changes.
windowSoftInputMode=adjustNothing (edge-to-edge default).
When keyboard closed: offset = nav bar height (gesture bar).
When keyboard open: offset = keyboard height (includes nav bar).
imePadding() on Compose Box/Column with overlapping children
(BottomCenter-aligned dock over fillMaxSize LazyColumn) caused
the input to float to the top of the screen. The Compose inset
system doesn't work well with this overlay layout pattern.
Fix: remove all imePadding() usage, set windowSoftInputMode=
adjustResize in AndroidManifest. Android resizes the window
itself when the keyboard opens — the entire layout naturally
reflows, dock stays at the bottom, chat area shrinks. This is
the standard approach for chat apps with edge-to-edge.
- loadError auto-clears after 4s for unlock failures
- loadError reset on every conversation switch (was sticky)
- Token badge always shown (was hidden when usedTokens == 0,
making it invisible for all loaded chats)
Note: locked chat "Authentifizierungsfehler" happens because
POST /api/v1/auth/unlock is not deployed yet on the server.
After git pull + docker build on prod, biometric unlock works.
The root cause: imePadding on the bottom dock Column inflated it
by the full keyboard height, pushing it up into the chat content.
Fix: imePadding on the outer Box that contains everything. Now
the entire layout shrinks when keyboard opens — chat area gets
shorter, dock stays at the (new) bottom edge. No overlap.
Two issues prevented locked chats from opening:
1. context as? FragmentActivity silently returned null on some
devices where LocalContext.current is a ContextWrapper. Fix:
walk the ContextWrapper chain to find the FragmentActivity.
2. No fallback when biometrics unavailable — the chat just stayed
locked with no feedback. Fix: if biometrics not available (no
fingerprint enrolled, no hardware), skip biometric prompt and
unlock directly via server token. Biometric is still enforced
when available.
Extracted unlockAndOpen() to avoid duplicating the server unlock
+ message loading logic across biometric/fallback paths.
Root cause: gradient scrim (transparent at top) let chat text
bleed through when imePadding pushed the dock up. Fix: use solid
background color when keyboard is visible, gradient only when
keyboard is closed. Pills and spacer also hidden during keyboard.
Input field: pill shape instead of xl (rounder), higher opacity
(0.85/0.90 instead of 0.75/0.74) for cleaner look — less muddy
glass, more solid surface. Bottom spacer reduced to 4dp when
keyboard open (was 16dp) to sit tight above the keyboard.
Scroll buttons: only one shown at a time based on direction
(up when scrolled down, down when scrolled up), hidden during
streaming. Mutually exclusive instead of both visible.
Sidebar menu: replaced default Material3 DropdownMenu/MenuItem
with custom styled menu — dark surface color (1A2030 dark,
F8F9FB light), KaizenShapes.md rounded corners, 12dp shadow
elevation, custom MenuRow composable with proper spacing.
Three fixes:
1. Token badge only shown when usedTokens > 0 (not in old chats
where we have no streaming data). Pushed to far right with
Spacer weight. Reset to 0 on new chat and conversation switch.
2. Mode pills hidden when keyboard is open (WindowInsets.isImeVisible)
— prevents the bottom dock from pushing too far up and overlapping
chat content. Input field stays compact at the bottom.
3. ModelPill no longer constrained with weight(fill=false) which
was causing layout issues.
Sources are now collapsed by default with a "Quellen (N)" header
that expands/collapses on tap (matching web's SourcesBlock behavior).
Each source renders as a full-width vertical card (not a small
140dp chip) with title, URL in accent color, and optional snippet.
Tapping a source opens it in the browser via ACTION_VIEW intent.
Room crashed on launch with schema hash mismatch because
MessageEntity gained reasoning+sources fields but the DB version
stayed at 1. fallbackToDestructiveMigration(false) only covered
downgrades, not same-version schema changes.
Fix: version 1→2, fallbackToDestructiveMigration(true) so any
future schema change auto-wipes the cache (it's a read-cache,
server is source of truth, rebuilds on next fetch).
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.
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.
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%).
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.
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.
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.
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).
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.
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
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.
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).
Moved AttachmentMenu out of the bottom dock Column into its own
floating overlay positioned above the dock with padding(bottom=140dp).
Pills and input stay in place, menu hovers over them.
Replaced the horizontal circle buttons with a vertical GlassSurface
panel. Each row has a 40dp round tinted icon + label, matching the
app's glass design system. Slides up above the input with animation.
Replaced the generic DropdownMenu with a row of three large (56dp)
circular icon buttons: Camera (accent), Gallery (indigo), File (emerald).
Slides in/out with animation above the chat input. Toggle on +.
Replaced the generic file picker with a dropdown menu offering three
options: take photo (camera preview), pick from gallery (image/*), or
choose any file (*/*). Camera captures are compressed as JPEG and
uploaded like any other file. Upload logic extracted into shared
uploadPickedFile helper.
Parse tool events into thinking/analyzing/inProgress/completed states
instead of showing all as generic "Tool" rows. Handles type "thinking"
(shows "Verarbeitet…"), "analyzing" (shows filename), "start"/"end"/
"error" with proper icons and elapsed time. Empty/thinking-only events
no longer show a confusing wrench icon.
ModelSheet, ChatComponents, SettingsScreen had hardcoded strings like
"Ausgewählt", "Modell auswählen", "Zurück" etc. instead of using the
existing string resources. Now all UI text uses stringResource(R.string.xxx)
for proper de/en localization.
All 7 files that imported Amber/AmberDeep/OnAmber/BlobX now read from
LocalKaizenAccent.current instead. Theme switching in settings actually
changes the UI now: message bubbles, send button, orb, blobs, sidebar
highlights, model sheet, stream blocks, login screen, and settings
toggles all respond to the selected theme.
On app start, after syncing the conversation list, prefetch messages
for any conversation not yet cached in Room. This ensures all chats
are readable offline, not just previously opened ones.
SettingsOptionRow was a horizontal Row cramming icon + title + wide
controls (theme circles, appearance pills) into one line. The text
got squeezed to near-zero width, breaking character by character.
Changed to vertical layout: title/description on top, controls below.
Orb + greeting + headline were pinned to the top with a fixed spacer,
leaving the bottom half empty. Now vertically centered with padding
that accounts for the floating top bar (76dp) and bottom dock (160dp).
Active pills now use cs.surface at high opacity instead of transparent
Amber overlay. Removed amber from borders and icons, unified to neutral
glass border. Active state distinguished by higher fill + semibold weight.
Content was bleeding through the floating top bar (model pill + menu)
and bottom dock (mode pills + input) because they had no background.
Added vertical gradient scrims to both areas and bumped ModelPill
surface alpha from 0.7 to 0.92.
StreamConsumer.Incremental now returns StreamState (content, reasoning,
tools, query, sources) instead of just visible text. All sentinel sections
are parsed and structured:
- \x01 REASONING: captured as separate string
- \x02 SOURCES: parsed as List<SearchSource> (title, url, snippet)
- \x03 TOOL: parsed as List<ToolEvent> (type, name, args, result, elapsed)
- \x04 QUERY: captured as search query string
UI components:
- ReasoningBlock: collapsible "Gedankengang" with expand/collapse animation
- ToolEventsBlock: shows tool-call steps with status icons (start/end/error)
- SourcesBlock: web search sources as compact cards with domain extraction
Message model extended with reasoning, tools, query, sources fields.
KaizenApi.chat() returns Flow<StreamState> instead of Flow<String>.
ChatScreen wires all fields to Message during streaming.
Fixed: empty append() on Incremental no longer prematurely ends tool phase.
StreamConsumerTest rewritten to verify all parsed sections.
Settings now persist across app restarts via SecureStore:
- Theme (KaizenThemeId), appearance (Light/Dark/System), username, locale
- All loaded on SettingsViewModel init, saved on every change
Server sync: fetchMe() returns name + email from /api/v1/me,
populates SettingsViewModel on login and each foreground resume.
Language selector: System / Deutsch / English pill toggle in Settings.
Locale preference stored; runtime switching deferred to next iteration.
MeResponse expanded to parse name + email (was defaultModel only).
SettingsViewModel tests updated for store-less default state.
Inter Variable (v4.1, 859 KB): full type scale with 9 weight axes
(W100–W900), negative tracking at display sizes, positive at labels.
Theme picker: SettingsViewModel now uses KaizenThemeId from the design
system (AMBER/BLAU/MONO/AURORA). MainActivity wires selectedTheme +
appearanceMode to KaizenTheme composable — switching themes in Settings
instantly propagates to the entire app.
No server sync needed — web stores theme in localStorage, app stores
locally. Backend /api/v1/me has no uiTheme field in the DB.
Settings strings migrated to string resources (de + en).
SettingsViewModelTest updated for new enum values + System default.
Root cause: Float32 accumulated ~0.017 error across 6 matrix multiplications
(RGB→LMS→Lab→Lab→LMS→RGB), producing negative linear-green values that got
clamped to 0 by toSRGB().
Fix: all matrix math now uses Double. Forward LMS matrix corrected to match
Björn Ottosson's reference implementation. Variable shadowing eliminated.
All 3 OklabTest cases now pass (were failing since initial commit).
German (default) in values/strings.xml, English in values-en/strings.xml.
Matches web app's next-intl de/en setup.
Migrated: greeting, hero headline, chat input, error messages, mode pills,
suggestion pills, sidebar (brand, search, dates, logout, settings),
model sheet, login screen (welcome, fields, errors, submit button),
conversation labels (locked, pinned).
ChatModels: Suggestion/ChatMode now use resource IDs instead of hardcoded strings.
greeting() is now @Composable (uses stringResource).
ModePillsRow: selected state uses resource ID (Int) instead of String.
Date labels (HEUTE/GESTERN/FRÜHER) localized via DateLabels data class.
Amber blobs were cold (blue/indigo) but web uses warm (gold/coral/honey/teal).
Mono had slate chroma but web is pure achromatic (zero chroma).
Aurora dark primary was magenta but web swaps to gold in dark mode.
All four themes now pixel-match the web app's OKLCH values.
Foundation for the premium UI overhaul:
- P3 DisplayP3 color pipeline (all tokens in wide gamut)
- SquircleShape (iOS-style superellipse with G2 continuity)
- KaizenShapes token system (xs/sm/md/lg/xl/pill/circle)
- Multi-layer ShadowStack system (level 0–4)
- GlassSurface composable with RenderEffect blur + API fallback
- Motion tokens (easing curves, spring specs, durations)
- Tilt sensor state (GAME_ROTATION_VECTOR, lifecycle-aware)
- 4-theme system (Amber/Blau/Mono/Aurora) with AccentScheme
- ThemeRegistry + CompositionLocal for accent propagation
- Unit tests for colors, shapes, shadows, themes
kotlinx.serialization defaults to not encoding properties with default
values. SaveMessage.kind = "chat" was never sent, causing the backend's
sanitizeSaveableMessage() to reject every save with 400. This also
broke title generation since it was gated behind a successful save.
Room 2.8.4 as local SQLite read-cache for conversations and messages.
Sidebar and chat history load instantly from cache, background refresh
syncs with the server. Streaming stays in-memory, flushed to Room after
completion. Includes ChatViewModel, repositories, mappers, TypeConverters,
and unit tests.
- Parse and render message attachments from loaded conversations (images
inline via OkHttp + in-memory cache, other kinds as icon chips)
- Wire up the + button with Android file picker (ActivityResultContracts)
- Upload files to /api/v1/upload with progress tracking and preview chips
- Send attachments with chat requests and persist them in saved messages
- Markdown: horizontal rules, blockquotes, links, code block copy button
Replace null-returning fetchModels/fetchConversations with FetchResult<T>
sealed interface. Error banner now shows the exact cause (HTTP status,
DNS, SSL, timeout, JSON parsing) instead of "Verbindung zum Server
fehlgeschlagen".
- Eliminate recomposition hell: isolate auto-scroll via derivedStateOf + snapshotFlow
- Preserve partial response on connection drop instead of replacing with error
- O(1) incremental stream parser (StreamConsumer.Incremental) replacing O(N²) re-scan
- Parallel conversation creation via async/await to reduce TTFT
- TCP/TLS prewarm on app resume via LifecycleResumeEffect
- Surface API failures with Log.w and UI error banner instead of silent emptyList()
- Fix nullable KaizenModel.name crash in ModelSheet
Sidebar now lists the user's conversations from GET /api/v1/conversations
(date-grouped, pinned first, active highlighted) instead of mock data. Sending
creates a server conversation on the first message, persists each exchange via
POST .../messages, and auto-titles new conversations. Tapping a chat loads its
stored messages; new-chat/logout reset the active conversation. Linear model
(no branching); locked chats are skipped (no in-app unlock yet).
Top pill shows the active model; tapping opens a bottom sheet with a
response-speed segment (Instant/Normal/Max -> reasoningEffort + throughput),
model search, favorites, provider grouping and a Vertex hoster badge.
Model/speed/favorites persisted in the encrypted store; chat now sends the
additive reasoningEffort/preferThroughput v1 fields.
Replaces the Phase-0 fakeReply mock with the real v1 API. Deployment-agnostic:
one binary, one Authorization: Bearer contract, server-URL field switches between
the official instance and any self-host.
- auth/LoginScreen: server-URL + email + password -> POST /api/v1/auth/token
- net/SecureStore: kzn_ token in EncryptedSharedPreferences (KeyStore AES-256-GCM)
- net/SessionViewModel: snapshot-state login/logout routing, restores on launch
- net/KaizenApi: POST /api/v1/chat streaming + GET /api/v1/me for the default model
- net/StreamConsumer: Kotlin port of lib/chat/stream-consumer.ts (JVM-unit-tested)
- deps: okhttp, kotlinx-serialization, security-crypto; INTERNET permission
Code-complete; not yet built/run on device (no Android SDK on the dev Mac).