Move all business logic and state (~35 variables, ~15 functions) from
the 1540-line ChatScreen @Composable into ChatScreenViewModel.
ChatScreen is now a thin rendering layer (~430 lines) that reads VM
state and wires UI events to VM methods. Android-framework-only concerns
(ActivityResultLaunchers, DrawerState, LazyListState, keyboard insets)
stay in the Composable.
Changes:
- New ChatScreenViewModel.kt: messages, streaming, conversation mgmt,
sidebar actions, file upload, voice recording, TTS, live call, unlock
- ChatScreen.kt: pure UI, delegates all logic to vm.*
- Haptics.kt: add createHaptics(Context) factory for non-Composable use
- SessionViewModel: store nullable for test construction
- MainActivity: instantiates ChatScreenViewModel
Tests: 12 new unit tests for ChatScreenViewModel (state transitions,
newChat reset, mode toggles, edit/delete, password dialog lifecycle).
All 90 tests pass.
Security:
- Fix JSON injection in deleteMessage (raw string → serialized DTO)
- Separate OkHttpClient for streaming (30s timeout on normal requests,
infinite only for chat stream)
- Disable backup (EncryptedSharedPrefs without KeyStore → crash on restore)
- Block cleartext traffic explicitly
- Strip server URLs from log output
Performance:
- Share OkHttp connection pool across KaizenApi + LiveClient
- Eliminate duplicate fetchMe/fetchDefaultModel call on login
- Parallelize prefetchMissing (was sequential per conversation)
- Stream file uploads directly from ContentResolver (no full readBytes)
- Cache assistant message index during streaming (O(1) vs O(n) per chunk)
- Build chat history before adding assistant placeholder (no filter needed)
Bugs:
- Fix send() race condition (isStreaming set before async work)
- Move voice recording file read to IO dispatcher
Turn persistence: onTurnEnd handler creates conversation if needed,
adds user + assistant messages to UI list, caches in Room. Server
already persists via WS-Server — app only needs local state sync.
Model check: CallButton only triggers Live Call when a vertex:*live*
model is in the catalog (liveAvailable). Falls back to PTT otherwise.
Permission: RECORD_AUDIO runtime request via ActivityResultContracts
before starting the call. Denied → toast, no crash.
Title generation: endLiveCall triggers generateTitle + sidebar refresh,
same pattern as text chat.
MediaPlayer.setDataSource(url) with HTTPS URLs can silently fail due to
certificate issues, redirect handling, or listener race conditions (listeners
were set after prepareAsync). Fix: download the WAV file via OkHttp (same
client as all other network calls) to a temp file, set listeners before
prepareAsync, play from local path. Temp file cleaned up on completion/error.
Edit creates a sibling branch (same parentId) instead of deleting messages.
Regenerate creates a new assistant branch. BranchNavigator UI shows chevron
arrows with "1/3" counter on messages with siblings. Tree state (activeRootId,
activeChild) synced to server via PATCH. New ChatTree data structure mirrors
the web frontend's tree.ts. StoredMessage.parentId parsed from API response.
Replace deprecated ClickableText with LinkAnnotation.Url + Text (Compose
native URI handling). Add GET /api/v1/meta call after login and on each
app resume to detect API version skew — shows dismissible amber banner.
Keep [x] items as context for future sessions. Added new done items
(text selection, ChatInput capsule, visual refresh). Fixed section
numbering 1-8. Updated media cache to reflect custom disk cache.
ChatInput redesigned: single row (Plus | Text | Tune | Mic | Send).
Pills hidden by default, expandable via tune icon with slide animation
inside the same GlassSurface card. Shape lg (28dp) instead of pill.
ModePillsRow moved from separate floating row into ChatInput as
pillsContent lambda. Bottom dock scrim simplified.
New Chat button (PenLine icon) added to top bar next to TokenBadge.
LazyColumn bottom padding 260→140dp (dock is now smaller).
Text selection: SelectionContainer wraps both user and assistant
messages — long-press to select, copy, share.
Sidebar profile: avatar 30→38dp with radial gradient, rounded lg
shape, icon buttons with subtle backgrounds, bigger text (15sp W400).
Markdown: heading spacing 18→24/20dp, list items better aligned
(8dp gap, bullet muted), blockquote bar 4dp inset + 12dp gap.
Hand-drawn Menu, Plus, Mic, AudioLines, Brain, SlidersHorizontal,
Globe, Image with 1.0px stroke (vs Lucide's 1.5px) to match the
lighter W300 typography. Registered in KaizenIcons — all usages
update automatically, no call-site changes.
Single source of truth for error, success, indigo, link, and dropdown
colors. Replaces ~25 scattered Color(0xFF...) literals across 10 files.
Syntax highlighting and glass-surface tints intentionally left local.
Single button instead of two mutually exclusive ones. Arrow direction
is based on proximity to bottom: near bottom → arrow up (scroll to top),
not near bottom → arrow down (scroll to end). Updates instantly as the
user scrolls. Hidden when already at the target end.
Old logic had an if/else that always showed ArrowUp first (because
!atTop was always true when scrolled down), making ArrowDown unreachable
in most scroll positions.
statusBarsPadding() was on the inner Column (only pushed content down),
but the GlassSurface itself started at a fixed 12dp from the top edge —
on devices with tall notches (S25 Ultra) the sidebar header sat behind
the system clock/icons. Moved statusBarsPadding() to the GlassSurface
modifier so the entire glass panel starts below the status bar.
ReasoningBlock: accent-tinted background + gradient border (matches pill
language), KaizenShapes.md (16dp radius), larger padding, better line
height (20sp), softer icon tint. Replaces flat gray box with solid border.
SourceCards: gradient border instead of solid, KaizenShapes.md, shows
domain instead of full URL in the link line, slightly larger padding.
Both blocks now use the same visual language as the rest of the app —
gradient borders, accent tinting, squircle shapes.
Pencil icon on user messages (next to copy, before delete). Tap removes
the edited message and all messages after it from the UI + server, then
puts the text back into the input field for editing and resending.
Only shown on user messages with text content (not voice-only), and only
when not currently streaming.
Voice auto-send was saving messages to the server but not caching them
in Room with correct sortOrder. When navigating away and back, the app
loaded from Room cache (empty for voice messages) then from server,
where the tree-based ordering could differ from insertion order.
Now caches both user and assistant messages in Room with proper sortOrder
after the server save succeeds, matching the normal send() flow.
Audio-only user messages (no text, only audio attachments) now render as
a clean "🎤 Sprachnachricht" pill instead of the ugly filename chip
(voice_1782170416740.m4a). Detects via attachments.all { kind == "audio" }
&& content.isBlank(). Regular file attachments are unaffected.
When search mode is active and a vertex: model is selected, a second pill
appears next to "Suche" with a dropdown to choose between Google Search
(default) and Enterprise Web Search grounding. The selection is sent as
webSearchProvider in the chat request body. Matches the web frontend's
OptionPill for webSearchProvider.
Only visible for Vertex models — OpenRouter uses agentive web_search
server-tool which has no provider toggle.
Sampling panel: width 260→220dp, padding reduced, spacing 14→6dp between
parameters, checkbox 20→16dp, slider height capped at 28dp, removed extra
spacer between label row and slider. Panel is now ~40% shorter.
Pills visibility: inactive background alpha raised (dark 0.06→0.10, light
0.04→0.07), border alpha doubled (dark 0.08→0.14, light was white-on-light
invisible, now uses black 0.10), icon tint 0.60→0.75, text 0.70→0.80.
Active state also strengthened. Pills are now clearly visible in both modes.
32-bar waveform visualization that reacts to microphone amplitude in
real-time. MediaRecorder.getMaxAmplitude() polled every 60ms, normalized
to 0-1 range, fed into a scrolling bar chart (new bars enter right, old
shift left). Bars use accent color — louder = taller + more opaque,
quiet = short + muted. Replaces the static "Aufnahme läuft…" text.
Voice messages now upload and send automatically when recording stops —
no manual send button needed. The input area transforms during recording:
text field replaced by a centered pulsing red dot + "Aufnahme läuft…" label,
with a single centered red stop button below.
The old flow (record → attachment chip → manual send) treated voice like
a file attachment. New flow: tap mic → record → tap stop → auto-upload →
auto-send → stream response. Matches the expected voice message UX.
Buttons: 30dp→34dp with squircle shape (KaizenShapes.sm), subtle tinted
background (dark: white 4%, light: black 2.5%), icons 16→17dp. Tint alpha
raised from 0.35→0.50 (copy/regenerate/volume) and 0.6→0.70 (delete) so
buttons are actually visible in both light and dark mode. Spacing 2→4dp
between buttons, top padding 4→6dp.