Status: Complete — M0–M5 done, behavioural parity reached and signed off on-device (2026-08-04)
Owner: hatsyrei
Target: native Android (Kotlin + Jetpack Compose), Android-only, side-by-side with the existing React Native maid app during migration.
Closed 2026-08-04. The port is done: every milestone in §7 is complete and the app is the primary target. This document is retained as the record of the port — its analyses (§10, §11–§11.2) remain the reference for why things are built the way they are. Items still listed as deferred (§7.1) are post-parity enhancements, not port gaps; new work is tracked as ordinary issues from here on.
Progress at a glance (2026-07-29): Buildable/installable Compose app streaming real chat against an OpenAI-compatible endpoint on a physical device. Conversation-tree logic + reasoning ported with passing unit tests; Room persistence with incremental diff writes; DataStore settings; OkHttp SSE streaming + model listing; full chat UI with message controls (regenerate/revise/modify/copy/delete), branch navigation, a navigation drawer (select/rename/delete conversations), Markdown rendering (library-based, incl. user messages, with an incremental streaming path), a model-selector pill, endpoint subnet scan, chat export/import (RN-compatible), a draggable scroll thumb, and a real launcher icon (Maid Ai monogram). Signed release APK ≈ 2.0 MB (was ≈1.3 MB before the 2026-07-29 Compose/renderer bump — see §11.2). Remaining big rocks: on-device parity sign-off against the RN app, collapsible-reasoning/markdown-image polish, and the parity backlog in §10.
- Decouple from upstream. The RN app is coupled to Expo/React Native release cadence. Every SDK bump (e.g. the Expo 56 migration) drags in regressions we don't own: edge-to-edge enforcement breaking popover positioning,
expo-system-uireappearing, Gradle-10 deprecations fromnode_modules, keyboard-controller not emittingheight=0on API≥30 resume. Native lets us own the entire stack and adopt platform features on our schedule. - Kill RN-specific tech debt. Several documented, currently-unfixable bugs are RN-induced and simply do not exist natively:
- Keyboard-inset-stuck + drawer-unpainted after returning from a file picker (upstream
react-native-keyboard-controller+react-native-drawer-layout). - Swipe-gesture-vs-native-ripple conflict (RNGH pan hitSlop suppressing
android_ripple). - ~~
<Markdown>re-parsing the whole growing message every ~80 ms during streaming (O(n²) per response).~~ Retracted 2026-07-29. This one is not RN-induced and did not disappear in the native port — the Compose implementation reproduced it, and worse (once per token rather than throttled to ~80 ms). Measured and documented in §11.1. Genuinely fixed 2026-07-29 (§11.2), not by the port itself but by adopting the renderer's incrementalStreamingMarkdownState.
- Keyboard-inset-stuck + drawer-unpainted after returning from a file picker (upstream
- Size & battery. Drop Hermes, the RN runtime, Reanimated/Worklets
.sos, and the JS bundle. Target a ~5–8 MB APK (from ~20 MB) and remove per-token JS↔native bridge crossings during streaming. - iOS is explicitly out of scope. The shipping pipeline is already Android-arm64-only.
- No iOS / web targets.
- No local on-device model inference (already removed from the RN app).
- No feature expansion during the port — reach behavioral parity first, then iterate.
| Concern | RN today | Native target |
|---|---|---|
| Language / UI | TypeScript + React Native + Expo | Kotlin + Jetpack Compose |
| Design system | Material 3 (hand-rolled tokens) | Compose Material 3 (androidx.compose.material3) |
| Navigation | expo-router (file-based) | navigation-compose (or a small sealed-class nav) |
| Persistence (messages) | expo-sqlite + hand-written incremental diff |
Room (KSP) — done (data/db/: entity/DAO/database + MessageRepository incremental diff, WAL); legacy JSON snapshot migrated in on first launch |
| Preferences | @react-native-async-storage + use-stored-* hooks |
Jetpack DataStore (Preferences) — done |
| HTTP / streaming | openai SDK over expo/fetch |
OkHttp 5 (SSE) — done |
| Markdown | @novastera-oss/react-native-markdown-display |
Done: com.mikepenz:multiplatform-markdown-renderer-m3 v0.45.0 (pure Compose, Material 3). ui/markdown/Markdown.kt wraps it as MarkdownText (settled messages, parsed once via the synchronous parseMarkdown() and cached) and StreamingMarkdownText (in-flight replies, incrementally parsed via StreamingMarkdownState), body pinned to bodyMedium. See §11.2. |
| Images (markdown) | expo-image / Glide | Coil |
| Clipboard | expo-clipboard | ClipboardManager / Compose ClipboardManager |
| File pick / export | expo-document-picker | Storage Access Framework (ActivityResultContracts) |
| Keyboard insets | react-native-keyboard-controller | Compose imePadding() / WindowInsets |
| Gestures / drawer | RNGH + react-native-drawer-layout | ModalNavigationDrawer + Compose gestures |
| Async | Promises | Coroutines + Flow |
| DI (optional) | — | Hilt (or manual — keep minimal early) |
- JDK 21 (
~/.local/jdks/jdk-21), compiling to JVM 17 bytecode (no separate toolchain provisioning). - Android SDK: compileSdk 37, targetSdk 36, minSdk 26 (the floor of
androidx.javascriptengine, used by the JavaScript tool). compileSdk is the floor imposed by markdown-renderer0.45.0; targetSdk is held at 36 deliberately, since compiling against newer APIs is independent of opting in to new runtime behaviour. - Gradle 9.8.0, AGP 9.4.1, Kotlin 2.4.20, KSP 2.3.12, Compose BOM 2026.09.00, Room 2.8.5, OkHttp 5.5.0.
- AGP 9 supplies built-in Kotlin, so
org.jetbrains.kotlin.androidis no longer applied (it is incompatible with AGP 9's new DSL). AGP pins KGP/KSP to its own baseline, so our higher versions are declared on the rootbuildscriptclasspath (kotlin-gradle-plugin,symbol-processing-gradle-plugin). androidx.compose.material:material-icons-coreis now an explicit dependency; recentmaterial3no longer brings it in transitively.applicationId = com.hatsyrei.maidnative(distinct fromcom.hatsyrei.maid) → installs side-by-side.
Derived from the current RN app. Each item is a parity target for the native app.
- OpenAI-compatible base URL (default
https://api.openai.com/v1), editable, with a reset-to-default button. - API key (required only for the official OpenAI endpoint;
local-openai-compatibleplaceholder allowed otherwise). - [–]
Custom default headers (key/value map).Dropped 2026-07-29 — unused in practice. The RN app exposes an editor for it; neither of us has ever populated it. Not worth the settings-screen surface area. - [–]
Custom request parameters (arbitrary map merged into the completion body; UUID-keyed rows in the editor).Dropped 2026-07-29 — same reason. Note thatOpenAiClientstill accepts a parameters map, so only the editor UI is cancelled; wiring a caller back up later is cheap if a real need appears. - Model list via
GET /models, refreshed on endpoint change; auto-select first / preserve valid stored selection. (Refresh-on-focus not wired; refresh is manual + on endpoint change.) - Endpoint auto-discovery: subnet scan for OpenAI-compatible hosts. (
data/remote/EndpointScanner.kt, port of RNscan-endpoint.ts: probeshttp://<ip>:8080/v1/modelsacross the local /24 then extended /21, 400ms timeout, 64-way concurrency. Scan button next to Base URL field opens the scan options dialog (port + subnet, persisted); confirming it starts the sweep, with a spinner while scanning and a check on success. Validating the URL already in the field was dropped 2026-08-11 — Refresh models covers it.)
- Streaming chat completions (SSE), incremental token append.
- Stop / abort mid-stream.
- [–]
Retry (maxRetries=3) parity.Dropped 2026-07-29 — the RN app inheritsmaxRetries: 3from theopenaiSDK default rather than choosing it. Against a local endpoint a failure is almost always "server is down", where silent retries just delay the error and burn radio; against the official API, a hard failure surfacing immediately is the more honest behaviour. Manual resend is one tap. - Drop trailing empty assistant placeholder before sending (prevents llama.cpp assistant-prefix corruption).
- "Reasoning" content: parsed (
domain/Reasoning.kt) and rendered in a collapsible section (default collapsed, chevron header). - [~] Streaming render throttle: using
Flow.buffer(); conflation/sampling not yet tuned. Fixed a separate bug where org.json returned literal"null"for the openingcontent:nulldelta. Update 2026-07-29: a render-path throttle is no longer the priority lever it was. The quadratic markdown re-parse it would have masked is gone — the streaming bubble now parses incrementally (§11.2), andsnapshotFlowconflation there already collapses token bursts that outrun the parser.
- Port
message-nodes(domain/tree/): nodes withid/role/content/root/parent/child/metadata, branching,getConversation,hasNode, sibling navigation, delete/subtree, makeRoot, etc. - Edit-and-resend (revise), edit-in-place (modify), regenerate (branch + stream), delete (node/subtree), copy.
- Branch navigation controls (prev/counter/next), hidden when a single sibling.
- Test-first: Kotlin test suites (
MessageTreeTest,ReasoningTest) mirror the JS behavior; green.
- Room schema mirroring the
messagestable (id, role, content, root, parent, child, metadata), WAL. (data/db/:MessageEntity/MessageDao/MaidDatabase; WAL journal mode enabled. One-time migration seeds Room from the legacyfilesDir/messages.jsonsnapshot, then retires the file.) - Incremental diff writes (only changed rows upsert, vanished ids delete). (
MessageRepositorydiffs each save against a last-persisted snapshot:@Upsertonly changed/new nodes, delete only vanished ids, and skip the DB entirely when nothing changed. Writes are coalesced through a CONFLATED channel with a single consumer so bursts collapse to one write and diff state is never touched concurrently.) - [~] Structural-vs-content save distinction: persists on structural change and at stream end; per-token content is not persisted (churn suppressed) — but that means a force-close mid-stream loses the partial reply.
- Hydrate on load (root restored from stored mappings). (Mid-stream crash resilience pending — see above.)
- Hardcoded dark theme seeded from
#2196F3(Compose M3 color scheme). (Dynamic color optional/later. AMOLED-tuned: everyColorSchemerole is set explicitly inui/theme/Theme.kt— any role left todarkColorScheme()'s defaults falls back to Material's baseline dark neutrals, which are purple-tinted (surfaceContainerLow#1D1B20,surfaceContainer#211F26,surfaceContainerHigh#2B2930) and clash with the blue seed. Replaced with a black-anchored blue-neutral ramp:background/surface/surfaceDim/surfaceContainerLowest= pure #000000, containers #0C1015 → #1F2630.surfaceTint= transparent so tonal elevation can never lift the canvas off black.) - Composer pill (rounded, borderless multiline input, filled send/stop button, enabled/disabled transition).
- [~] Conversation list on a single tonal container; role labels; Markdown body + code/blockquote styling. (Markdown via
multiplatform-markdown-renderer-m3; both user and assistant messages render as Markdown; markdown image rule pending — needs Coil.) - Long-press message menu (revise/modify/copy/delete; regenerate for assistant). (Anchored to the touch point; trailing M3 icons; delete behind a confirm dialog.)
- Model selector pill + dropdown menu in the top bar (RN parity). (Pill hidden when no models available; dropdown menu centered on the pill.)
- [~] Navigation drawer: conversation list, rename, delete (with confirm dialog), constrained width (right sliver), keyboard dismissed on open. (Export (per-chat), import (multi-file), and backup-all done via SAF; RN-compatible JSON format.)
- Custom scroll thumb. (
ui/chat/DraggableScrollbar.kt: draggable scroll thumb for the conversation view.) - Edge-to-edge with correct status/nav bar insets. (Fixed keyboard double-inset via
windowSoftInputMode=adjustResize. Auto-scroll removed 2026-07-27; instead a bottom spacer (viewport − 96dp, a trailingSpaceritem underBoxWithConstraints) lets the user scroll the last message up near the top and scroll ahead to watch streaming text — mirrors RN commitdd8fb76.) - App icon + Android 12 splash. (Real adaptive launcher icon — Maid Ai monogram foreground with padding. The splash is the platform-generated one: API 31+ builds it automatically from the adaptive icon plus the theme's
windowBackground, whichTheme.MaidNativesets to@color/ic_launcher_backgroundso the two match and the transition reads as deliberate. Noandroidx.core:core-splashscreenand nowindowSplashScreenAnimatedIcon/postSplashScreenTheme— API 26–30 therefore gets a plain coloured window rather than an icon splash, which is accepted.)
com.hatsyrei.maidnative
├── data
│ ├── db (Room: MessageEntity, MessageDao, MaidDatabase)
│ ├── prefs (DataStore: endpoint, apiKey, model, headers, parameters)
│ └── remote (OpenAI client: models list, streaming completions, endpoint scan)
├── domain
│ ├── tree (MessageNode + branching ops, ported from message-nodes)
│ ├── tools (on-device tools, tool-call storage + `{{tool:N}}` marker parsing)
│ └── model (domain types)
├── ui
│ ├── theme (Color, Theme, Type — M3)
│ ├── chat (ChatScreen, ChatViewModel, composer, message list, menus)
│ ├── settings (SettingsScreen, fields)
│ └── drawer (conversation list, rename/export/import)
└── MainActivity (edge-to-edge, NavHost)
State: ViewModel + StateFlow; streaming via Flow<String> collected in the VM. No global singletons beyond DB/DataStore/HTTP client (manual DI or Hilt).
- The native app lives in its own repository (
maid-native-experimental) as a standalone Gradle project with its own wrapper, independent of the RNmaidrepo. (It was originally prototyped insidemaid/native/and split out once the vertical slice was working.) - Distinct
applicationId(com.hatsyrei.maidnative) → both APKs install and run side-by-side on one device for A/B comparison during the port. - Data note: the two apps have separate sandboxes; the native app does not read the RN app's SQLite DB. If we want to carry conversations over at cutover, add a one-time import (read the RN DB via an exported backup file through SAF). Out of scope for the prototype.
- Cutover: once parity + on-device sign-off is reached, promote the native app to
com.hatsyrei.maid(or keep the new id and treat as a fresh install).
- M0 — Prototype: ✅ Done. Buildable/installable Compose skeleton, dark M3, side-by-side id, signing.
- M1 — Tree core: ✅ Done.
message-nodesKotlin port + test parity ✅. Room schema + incremental diff persistence ✅ (data/db/, WAL, one-time migration off the legacy JSON snapshot). Remaining gap: partial replies are not persisted mid-stream, so a force-close during generation loses the in-flight reply (§4.4). - M2 — Streaming: ✅ Done. OpenAI client (models + SSE completions + abort) ✅, settings (DataStore) ✅, model selection ✅, endpoint scan ✅. Retry parity dropped (§4.2).
- M3 — Chat UI: ✅ Done. Message list, Markdown (
multiplatform-markdown-renderer-m3, user + assistant, incremental while streaming), reasoning (collapsible), composer, long-press menu, branch navigation, model-selector pill, draggable scroll thumb ✅. Markdown images deferred (needs Coil — §7.1); no other RN markdown feature is missing. - M4 — Drawer & data ops: ✅ Done. Drawer conversation list + rename + delete ✅. Export / import / backup-all ✅ (SAF, RN-compatible format +
validateMappingsport). Endpoint scan ✅. Custom headers/params editors dropped (§4.1). - M5 — Polish & parity sign-off: ✅ Done (2026-08-04). Edge-to-edge + keyboard-inset + scroll-hijack fixes, real launcher icon, Android 12 splash ✅. Size verification ✅ (2.0 MB signed arm64 vs ~20 MB RN — §11.2). Battery audit ✅ (§11/§11.1/§11.2: retry loop, per-token map copy, and the quadratic markdown re-parse all fixed; remaining items assessed and accepted). On-device A/B against the RN app ✅ — the parity backlog in §10 is closed out, with the last gestural fixes (multi-finger menus, half-open drawer, drawer open-swipe region) verified on-device 2026-08-04.
None of these block the sign-off above; each is either an enhancement beyond RN parity or a polish item accepted as-is.
- Persist partial replies so a mid-stream force-close does not lose the in-flight response (§4.4). Matches RN behaviour today — the RN app loses it too — so it is an improvement, not a regression.
- Markdown images (Coil) — the one RN markdown rule not ported; no assistant reply we exercise emits images against a local endpoint.
- Composer font parity and a dynamic-theming pass (§4.5, §10 Composer).
- Server-side tools: list and run llama-server's own tools (
--tools,--mcp-servers-config) through its/toolsendpoint, reusing the tool-call loop (§10 Deferred enhancements → Tool calling). - Remote MCP servers (Streamable HTTP, bearer/header auth only) as another tool source, with per-tool toggles in the Tools dialog.
(Done: Markdown renderer swap, Room persistence, endpoint scan, export/import, model-selector pill, draggable scroll thumb, real launcher icon, collapsible reasoning, Android 12 splash, on-device verification of the §11.2 rework, scroll-position-after-Settings fix, menu/drawer gesture arbitration, customizable display names, on-device tool calling. Dropped: custom headers/params editors, retry parity.)
- Tree logic regressions — mitigate with test-first port (M1).
- Markdown fidelity — Markwon vs Compose renderer differ from the RN lib; budget a styling pass.
- SSE edge cases — reconnect/retry/
[DONE]/partial-chunk framing re-owned; cover with tests against a mock server. - Version drift — AGP/Gradle/Compose pinned in
gradle/libs.versions.toml; upgrades are deliberate.
./build.sh # clean debug build (auto-detects toolchain)
./build.sh install # clean debug build + adb install
# or drive Gradle directly:
./gradlew assembleDebug # or installDebug with a device attached
# APK: app/build/outputs/apk/debug/app-debug.apkRequires ANDROID_HOME (or a local.properties with sdk.dir) and JDK 17+ (JDK 21 used locally). See README.md.
Concrete bugs and visual-parity gaps noted while exercising the prototype on-device. Resolved items are kept with their fix notes rather than deleted, so the reasoning stays discoverable.
- Keyboard lift overshoot (FIXED 2026-07-23): the composer was raised by ~one extra nav-bar height because the content
Columnapplied both the Scaffold's bottom inset (.padding(padding), which includes the navigation bar) and.imePadding()(whose IME inset also spans the nav-bar region underadjustResize) — double-counting the nav bar. Fixed by inserting.consumeWindowInsets(padding)between them soimePadding()only adds the height beyond the already-consumed nav-bar inset. Verified on-device. - Message typography parity (FIXED 2026-07-27): the role label and message body sizes were swapped. Corrected to the RN scale (
utilities/typography.ts, standard M3): role label =titleMediuminprimary; message/markdown body =bodyMedium; reasoning =bodyMediumitalic inonSurfaceVariant; reasoning toggle =labelLargeinprimary. - Font parity: composer input font family/size still differs from the RN app. Match the RN typography (family + size + weight).
- Model selector pill (FIXED 2026-07-27): added a centered model-selector pill + dropdown in the top bar (hamburger | pill | settings), mirroring the RN layout. Model selection still also available in Settings.
- Drawer width (FIXED 2026-07-27):
ModalDrawerSheetconstrained to 85% width so a right-hand sliver of the chat shows behind it (RN parity). - New-chat creates an entry (FIXED 2026-07-27): the New-chat (+) action now creates the
systemroot node up front (ChatViewModel.newChat, mirroringdrawer-content.tsxcreateChat) and makes it the active chat, so the conversation shows in the drawer immediately; the firstsubmitattaches to that existing root. - Keyboard dismiss on open (FIXED 2026-07-27): opening the drawer now clears focus and hides the IME so it no longer overlays the conversation list.
- Back button parity (FIXED 2026-07-27): a
BackHandlercloses the open drawer (and Settings returns to chat) instead of the system Back leaving the app. - Drawer stays open on interaction (FIXED 2026-07-27): selecting a chat or tapping New-chat no longer auto-closes the drawer; it switches the active chat behind the open drawer and persists until the user swipes it away.
-
Long-press menu position (FIXED 2026-07-27): message and drawer long-press menus now pop up centered on the touch point via a raw
Popup+ customPopupPositionProvider(TapContextMenu), mirroring the RN app's zero-size anchor atpageX/pageY. (The earlierDropdownMenu(offset=…)approach drifted to screen edges for wide anchors.) -
Chat entry menu trigger (FIXED 2026-07-27): drawer chat entries now open their menu via long-press (custom
DrawerChatItemwith a selected-state pill), matching RN; the three-dot icon was removed. -
Menu styling (FIXED 2026-07-27): pop-up menus now use rounded corners and trailing M3 icons (Regenerate/Refresh, Modify/Edit, Revise/Send, Copy, Delete/Delete-red; Rename/Edit in the drawer), with a divider before Delete. Width tightened (
widthIn~168–172dp) + column padding so the trailing icon sits near the label and content clears the rounded edges; the model-picker dropdown got extra horizontal item padding too. -
Delete confirmation (FIXED 2026-07-27): both message-delete and conversation-delete now go through a confirm
AlertDialog. -
Simultaneous multi-finger long-press opened multiple menus — FIXED 2026-08-04 (analysed 2026-07-30). Long-pressing two distinct long-pressable items (two message bubbles, two drawer pills) with two fingers at once opened both context menus. Root cause was three layers each behaving as designed, with no coordinating authority:
- Pointer dispatch is per-hit-test-path. Android delivers multi-touch as one
MotionEventstream with multiple pointer IDs; Compose'sHitPathTrackerhit-tests each pointer independently and dispatches only the pointers that landed on a node to that node'spointerInput. EachdetectTapGesturesinstance therefore sees exactly one down and is unaware the other exists.detectTapGestureshandles multi-touch within one node (extra pointers cancel the gesture) but Compose provides no cross-node gesture arbitration — sibling nodes are peers. - Each long-press timer is its own coroutine.
awaitLongPressOrCancellationis awithTimeoutinside each node'sawaitEachGestureloop, so both expire ~500 ms after their respective downs and both set their ownmenuOpen = true. - Menu state was composable-local and popups are separate windows.
menuOpenwas a per-MessageItem/ per-DrawerChatItemremember, so nothing enforced mutual exclusion.TapContextMenuuses a rawPopup, which dismisses on outside touch viaFLAG_WATCH_OUTSIDE_TOUCH→ACTION_OUTSIDE; that only fires for aDOWNarriving after the popup window is attached. Both fingers went down ~500 ms before either popup existed, and Android never re-routes an in-flight gesture to a window added mid-gesture — so neither popup ever saw an outside touch.
Layers 1–2 are not fixable from app code (and the classic View system behaves identically —
ViewGroupsplits pointers across children by default since API 11, each arming its ownCheckForLongPress), so the fix targets layer 3: hoist the state.MenuController(ui/chat/ChatMenu.kt) holds a singleopenId: String?, published throughLocalMenuController(astaticCompositionLocalOf) and provided once inChatScreenaround the wholeModalNavigationDrawerso the message list and the drawer share one slot.MessageItem("message:{node.id}") andDrawerChatItem("chat:{root.id}") callmenus.open(menuId)fromonLongPressand readmenuOpenthroughremember(menuId) { derivedStateOf { menus.openId == menuId } }— thederivedStateOfmatters, since a plainmenus.openId == menuIdread would recompose every visible bubble on every menu toggle. A second long-press therefore replaces the first rather than adding a window. Each item also closes its own slot inDisposableEffect(menuId) { onDispose { … } }, preserving the old behaviour where a menu on an item scrolled out of theLazyColumndoes not come back when it scrolls in again. Rejected alternatives: puttingopenMenuIdinChatViewModel(transient UI state, and it would have to survive process death for no reason), and disabling pointer splitting on the container (breaks legitimate multi-touch in that subtree). - Pointer dispatch is per-hit-test-path. Android delivers multi-touch as one
-
A half-open drawer still accepted touches (long-press menus rode the sheet; taps selected chats) — FIXED 2026-08-04. Dragging the navigation drawer partway open (or touching it during the open/close settle animation) let a chat pill be used straight away: a long-press popped a context menu anchored to the pill's mid-drag position which then translated in lockstep with the drawer — including sliding off-screen with the sheet if the drawer was released closed — and a single tap simply selected that chat. Same family as the multi-finger case above (the drag finger and the touch finger are dispatched independently), but the visible symptom was acting on a surface still in motion. Three contributing behaviours:
- Drawer content is interactive at every offset.
ModalNavigationDrawerputsModifier.anchoredDraggableon its rootBoxand translates the sheet with a layoutoffset {}; hit testing uses the placed position, soDrawerChatItem'sdetectTapGesturesis live at any fraction. Nothing gates the sheet's gestures on the drawer's state, and the scrim only covers the main content, never the sheet. (Fully closed is safe only incidentally — the sheet is translated off-screen, so no touch can land on it.) - The drag doesn't cancel the long-press. Children see pointer events before parents in the main pass, and the long-press finger never moves, so
awaitLongPressOrCancellationexpires normally;anchoredDraggableis meanwhile advancing on a different pointer id, which the item's detector never observes. Popuptracks its anchor.TapContextMenupositions fromanchorBounds+ the touch offset, and Compose re-invokes thePopupPositionProviderwhenever the anchor's window bounds change — which is every frame of the drag — so the menu re-solved and followed the pill (clamped at the window edges byTapMenuPositionProvider).
Fix (revised twice on 2026-08-04):
ChatScreenderivesdrawerSettledand passes it toDrawerContent(interactive = …), which applies a singleModifier.blockPointerInputat theModalDrawerSheetroot: while unsettled it consumes every change onPointerEventPass.Initial, so nothing inside the sheet — chat pills, New-chat, Import/Backup, list scrolling — can act on a surface that is still moving, and the parentanchoredDraggablecannot start a new drag from the sheet either. An in-flight opening drag is unaffected, because hit paths are fixed atDOWNand that pointer's path never included the sheet. This replaced a first attempt that plumbedmenuEnabledinto eachDrawerChatItem(onLongPress = null+ a retractingLaunchedEffect), which guarded long-press only and left single taps live, so a half-open drawer could still select a chat.The gate itself needed a second correction, and the reason is worth recording:
drawerSettledwas first written ascurrentValue == DrawerValue.Open && !isAnimationRunning, which guards a half-open drag but not a half-close one. Neither term reacts to a closing drag —DrawerState.currentValuedelegates toAnchoredDraggableState.getSettledValue(), which staysOpenuntil the sheet actually settles somewhere else, andisAnimationRunningisdragTarget != null, which is false while a finger is dragging. The offset is the only exact signal, so the gate is nowcurrentValue == DrawerValue.Open && abs(currentOffset) < 1f. From material3 1.4.0 bytecode,ModalNavigationDrawer's measure policy setsDraggableAnchorsConfig.at(Closed, minValue)andat(Open, maxValue)withmaxValue = 0f(fconst_0), so the fully-open offset is exactly0f; the sub-pixel epsilon (rather than== 0f) is deliberate insurance, since a settle animation that landed a hair off zero would otherwise leave the drawer permanently inert.NaNbefore anchors are initialised compares false, which is the safe direction.Cost:
currentOffsetis a per-frameMutableFloatState, so unlike the first two attempts thederivedStateOfis recomputed on every frame of a drag or settle animation — two comparisons, and only while the drawer is actually moving. The reading scope still recomposes only when the boolean flips, so nothing downstream re-runs per frame. Also verified from foundation 1.11.4 bytecode:Initial-pass consumption is honoured by everything in the sheet — the tap family (clickable,combinedClickable, ourdetectTapGestures) all funnel intoprocessTapGesture, which callsawaitFirstDownwith the defaultrequireUnconsumed = true(maskiconst_3→ both defaults; the$defaultbridge setsiconst_1), and the drag/scroll path bails on consumed changes in slop detection. - Drawer content is interactive at every offset.
- Edit/Revise dialog was unusable for long messages — FIXED 2026-08-11. Modifying or revising a long message hid its tail behind the keyboard (the dialog never shrank), and tapping into the middle of the text placed the cursor correctly but snapped the view back to the top of the message, forcing a re-scroll. Two independent causes:
- The dialog could not shrink.
AlertDialogruns in a floating dialog window (DialogProperties.decorFitsSystemWindows = true→SOFT_INPUT_ADJUST_UNSPECIFIED, which the framework resolves to pan, not resize, for floating windows), and theOutlinedTextFieldinside it was unbounded, so it grew to the full height of the message. Panning can only shift a window that is already taller than the visible area.EditDialogis now a plainDialogwithusePlatformDefaultWidth = false, decorFitsSystemWindows = false(non-floating,ADJUST_NOTHINGon S+, insets dispatched to the content), whose root fills the window and appliessafeDrawingPadding(). The card is therefore bounded by the space above the IME and the field takesweight(1f, fill = false)inside it, so a height-bounded field scrolls internally. Because the window now spans the screen, Compose'sdismissOnClickOutsidecan never fire (DialogLayout.isInsideContentsees the full-size child), so adetectTapGestureson the root stands in for it, with a no-op detector on the card swallowing taps that land inside. - Focus scrolled to a stale cursor. Bounding the field did not stop the jump, because
CoreTextField(thevalue/onValueChangeoverload) explicitly brings the selection into view when the field gains focus —bringSelectionEndIntoView(value, …)from itstextFieldFocusModifier, using theTextFieldValuecaptured in the composition that ran before the tap.BasicTextField(value: String, …)keeps that value asTextFieldValue(text), whose selection isTextRange.Zero, so focusing scrolled to index 0 — the top. (The tap does set the cursor, but throughonValueChange, i.e. one recomposition too late; the class of race is acknowledged in the source as b/216790855.) Fixed by moving the field to theTextFieldStateoverload (rememberTextFieldState), whose scroll-to-cursor lives inTextFieldCoreModifier's measure pass and therefore reads the current selection — and whose tap handler requests focus and places the cursor in the same gesture. This is the same migration §10 → Settings defers for the endpoint fields; the edit dialog is the first place it is exercised.
- The dialog could not shrink.
- Reset-to-default endpoint (FIXED 2026-07-27): added a "Reset to default" chip next to the Base URL save action.
- Endpoint search: DONE — search/scan button next to the Base URL field opens the scan options dialog, which starts the subnet sweep on confirm (§4.1).
- Endpoint presets: DONE (2026-08-08) — a bookmarks button on the Endpoint section header opens a
ModalBottomSheetthat both saves the current Base URL + API key under a name and lists the saved pairs for one-tap recall, with per-row rename/delete. Keys are encrypted at rest with an AES/GCM key held in the Android Keystore (SecretCipher); ciphertext carries anenc1:prefix, and a Keystore that refuses to serve the key fails the save rather than writing the value in the clear; a stored value that no longer decrypts is reported as unreadable instead of read back as "no key set".allowBackupis nowfalseand the DataStore file is excluded from device transfer, so the ciphertext never leaves the device that can decrypt it. Empty API keys are savable by design (local endpoints). - Cursor cannot be dragged past the visible text in the endpoint fields (open, 2026-08-08). With a Base URL longer than the field, the cursor handle stops at the field's edge instead of scrolling the content to expose what lies beyond, so reaching an off-screen position means panning the text by hand first.
AutoSaveTextFielduses the legacyOutlinedTextField(value, onValueChange)overload, which is backed byCoreTextField; handle-drag auto-scroll (and the selection magnifier) landed only in theTextFieldState-backed overloads. The fix is a migration —OutlinedTextField(state = …, lineLimits = SingleLine, onKeyboardAction = …)for the URL andOutlinedSecureTextFieldfor the key, since the new API has novisualTransformationand masking moves toTextObfuscationMode— which also means reworking theremember(committed)re-keying that currently folds external settings changes back into the field. Deferred rather than done blind: the change is untestable on the machine it was written on.
-
Scroll position resets after visiting Settings — FIXED 2026-07-29 (verified on-device). Scroll down into a long conversation, open Settings, come back — the list jumped back to the top (index 0). Two independent causes, both had to be fixed:
MainActivity'sAnimatedContentswaps on aScreenenum and disposesChatScreenonce the transition ends.rememberLazyListStateisrememberSaveable-backed, butAnimatedContentdoes not wrap its content in aSaveableStateHolder(verified againstanimation-android 1.11.4bytecode — zero saveable references inAnimatedContentKt), so the saved position had nowhere to live. Fixed by adding arememberSaveableStateHolder()inMaidNativeAppand wrapping each branch inSaveableStateProvider(target.name). Keyed bynamerather than the enum itself because the holder's map is written into the activityBundle, whose keys must be Bundle-storable types.ChatScreen'sLaunchedEffect(state.root)— which clears the markdown cache and scrolls to item 0 on conversation switch — re-fired on every recomposition of the recreated screen, so it would have discarded the restored position anyway. Now guarded by arememberSaveablesettledRootthat records the root the effect last acted on, so it fires only on a genuine chat switch. This also stops the markdown parse cache being needlessly dropped every time the user glances at Settings.
Not addressed: expanded-reasoning toggles still collapse, because
MessageItemusesremember(node.id)rather thanrememberSaveable. That state is already lost on scroll-out (LazyColumnonly preserves saveable item state), so it is a separate pre-existing nitpick.
- Customizable user / assistant display names — done 2026-08-11. Role labels were hardcoded (
You/Assistant); they now rendersettings.userName/settings.assistantNamein the sametitleMediumlabel, edited in a Chat section under Theme in Settings and stored in DataStore (user-name/assistant-name, defaultsUser/Assistant, a blank entry falling back to the default). Kept per-app rather than per-conversation, and deliberately not written into the message tree or sent to the model, so the RN-compatible export/import format is untouched. - On-device tool calling — done 2026-09-24 (1.7.0). The model can call tools mid-reply; each call runs on the device and its result is fed back until the model answers.
- Opt-in. A Tools chip beside Sampling opens a dialog of per-tool switches, stored as a string set (
enabled-tools, empty by default). With nothing enabled notoolsfield is sent, so endpoints without tool support see the exact request they always did. - Tools.
get_datetimereturns the device's local ISO-8601 time with offset, weekday and IANA zone (the weekday because models derive it unreliably).roll_dicetakescount(1–100),sides(2–1000) andmodifier(±1000) and returns each roll plus the total. Bad arguments come back as{"error": …}so the model can retry; numbers are accepted as20,20.0or"20". - Loop.
StreamControlleraccumulatesdelta.tool_callsby index, runs the calls on IO, and streams again. Requests past 8 tool rounds are offered no tools, which forces an answer. Stop cancels a running tool; a call cut short is dropped with its marker. - Storage: one assistant node per reply.
contentholds the reply text with a{{tool:N}}line where each call ran;metadata.toolCallsholds the call data (id, name, arguments, result) keyed byN, as a JSON-array string so it survives Room, export and import unchanged and compares by value in the diff. A line counts as a marker only on its own line, for a known key, and once; anything else is text. Markers with only blank lines between them are one round.OpenAiClient.buildBodysplits them back intoassistant{content, tool_calls}+tool{tool_call_id}messages, so the model never sees a real marker. No Room migration. - UI. The bubble renders text segments as Markdown and each round as collapsible tool rows; while streaming, only the text after the last marker goes through
StreamingMarkdownState. Long-press keeps the normal menu, and Copy strips markers. Modify shows the whole reply, markers included; chips (N · name) edit a call's arguments (validated as a JSON object) and result, × removes a call with its marker, and a call whose marker was deleted by hand is dimmed and dropped on save. The chips collapse when the keyboard opens. - Known trade-offs. Reasoning from every round is merged into one
<think>block at the top. Exports open in the RN app with the marker lines as literal text. A marker-shaped line that is not a live marker (e.g. typed during Modify) is sent to the model as ordinary text; judged too unlikely to matter.
- Opt-in. A Tools chip beside Sampling opens a dialog of per-tool switches, stored as a string set (
- Linked files — done 2026-10-01 (1.8.0). With the File operations switch on in the Tools dialog (
file_operations, one switch for the file tools), the+menu's Link file (SAFOPEN_DOCUMENT) and New file (CREATE_DOCUMENTasapplication/octet-stream, so the provider keeps the user's extension; defaultnotes.md) stage a document as a tinted composer chip; no storage permission is needed. Pickers offertext/*, known textapplication/*types andapplication/octet-stream(providers type.log/.ini/etc. as that);TextFiles.isTextthen accepts a text MIME or an allowlisted extension. A New file skips the check, since the user named it. The picker's grant is persisted at pick time (read+write, read-only if the provider refuses write). On send the files go on the user node asmetadata.files(JSON-array string of{uri, name}), the composer lets go of them, and the request appends[Linked file: name]to that user turn; Revise carries them to the new branch. The model reaches every file linked along the active thread (deduped by URI): its requests carryread_file(by line:start_line/max_lines, default 500 / max 2000, each line prefixedN\t,end_line+total_linesto continue from, output capped at 50k chars with an over-long single line cut),search_file(plain-text, case-insensitive by default; one entry per matching line with its 1-based number and context lines numbered likeread_file, long lines cut to 400 chars around the match;total_matchesplus up tomax_results),write_file(replace orappend) andedit_file(edits: 1–50{old_text, new_text}applied in order, each an exact unique match against the previous edit's result; any failure saves nothing; a stringified array is accepted), with the names in their descriptions and as afileenum. UTF-8 text up to 1 MB only. Tool access is limited to URIs the app holds a persisted grant for, so an imported chat naming another URI (e.g.file://into app storage) gets an error. UI: a message shows its files as rows in the tool-row style; tap opens the built-in text viewer (read through the grant, 256 KB preview), × unlinks the file from every message of the chat. Chat properties lists the thread's files with the same rows, below the statistics (the message-count rows were dropped). Grants nothing links are released on unlink, message/chat delete and startup.
- Dependency refresh — 2026-10-05. Gradle
9.7.1→9.8.0, Compose BOM2026.08.00→2026.09.00,core-ktx1.19.0→1.19.1, Room2.8.4→2.8.5, org.json (tests)20250517→20260814. No source changes. Coil stays on3.6.0(3.6.3exists) because markdown-renderer-coil30.45.0still resolves3.6.0, and bumpingcoil-network-okhttpalone would mix Coil module versions. Everything else was already on the latest stable release. - Dependency refresh to current stable — 1.4.3 (2026-08-25). Everything moved to its latest stable release: Gradle
9.5.0→9.7.1, AGP9.3.1→9.3.2, KSP2.3.10→2.3.11, Compose BOM2026.06.01→2026.08.00, coroutines1.9.0→1.11.0, DataStore1.1.1→1.2.1, OkHttp4.12.0→5.5.0, markdown-renderer0.43.0→0.44.0. Kotlin2.4.10, Room2.8.4,core-ktx 1.19.0,lifecycle 2.11.0,activity-compose 1.13.0were already current. Nothing prerelease was taken, which is why AGP stays on9.3.x(9.4.0-rc01/9.5.0-alphaexist), material3 on1.4.0(1.5.0is alpha-only) and DataStore on1.2.x(1.3.0is alpha-only).- OkHttp 5 was the only source-visible break, and a small one:
Response.bodyis now non-nullable, so the tworesponse.body?.string().orEmpty()reads inOpenAiClient(listModelsand the/propsmodality probe) becomeresponse.body.string(). TheEventSourceListenersignature, theokhttp-ssecoordinates, andEndpointScanner'sDispatcher/Callbackuse are unchanged, and R8 produced no new missing-class warnings. - The renderer bump needed no source changes —
parseMarkdown(),StreamingMarkdownStateandmarkdownAnnotatorConfigare unchanged across0.43.0→0.44.0— and streaming render was re-checked by hand on-device before release. The three upstream quality issues noted at the end of §11.2 are still present in0.44.0. - Cost: signed release APK
1,981,989→2,067,616bytes (+85 KB), almost all of it OkHttp 5. Unit suite green (32/32).
- OkHttp 5 was the only source-visible break, and a small one:
- Deprecation warnings in
MessageItemcleared 2026-08-11.LocalClipboardManager→LocalClipboard(the copy action now writes aClipEntry(ClipData.newPlainText(…))from arememberCoroutineScope, since the new API is suspending), and the branch arrows moved toIcons.AutoMirrored.Filled.KeyboardArrowLeft/Right. - [TECH DEBT — SECURITY]
multiplatform-markdown-rendererupgraded to the latest release — RESOLVED 2026-07-29 (0.33.0→0.43.0). The pin existed because0.33.0was the newest version binary-compatible with Kotlin2.1.0/ Compose BOM2024.12.01; releases past it are built with newer Kotlin, and Kotlin metadata is a hard blocker (a2.1.0compiler cannot read it without-Xskip-metadata-version-check, which we will not use). Clearing it required the coordinated bump recorded in §3: Gradle9.5.0, AGP9.3.1(incl. the built-in-Kotlin migration), Kotlin2.4.10, KSP2.3.10, Compose BOM2026.06.01, Room2.8.4, compileSdk37. Both payoffs were taken on arrival —parseMarkdown()andStreamingMarkdownState, see §11.2. The historical analysis of the version floor is preserved in §11.1.- Note: the previously recorded floor of Kotlin
2.2.xwas wrong, and the corrected estimate of2.3.xwas also low.0.43.0shipskotlin-stdlib 2.4.0.
- Note: the previously recorded floor of Kotlin
Quick static audit of energy-relevant code paths. No continuous background work exists — the app has no services, no WAKE_LOCK/FOREGROUND_SERVICE permissions, no polling loops or timers, and no location/sensor usage (INTERNET is the only permission). Work is entirely user- or stream-driven. Findings and their dispositions (after review 2026-07-27) below.
-
Model-fetch retry loop — FIXED 2026-07-28.
ChatViewModel's settings collector auto-fetched models whenevermodels.isEmpty() || changedEndpoint. A failed fetch leavesmodelsempty, so the condition stayed true and re-firedrefreshModels()on every subsequent settings emission — repeatedly re-connecting to an unreachable endpoint in the background. Resolved with a one-shotautoFetchedModelsflag: the app now fetches models exactly once per launch (plus once per endpoint change) and never auto-retries after a failure. The user re-triggers a fetch manually via the Settings refresh/scan buttons. -
Whole-map copy per token — FIXED 2026-07-27 (was the one genuine inefficiency). Previously
MessageTree.updateContentdeep-copied the fullMappings(LinkedHashMapviacopyOf) and emitted a freshChatUiStateon every token → O(N) allocation churn per delta for large conversations. Resolved by decoupling the streaming buffer from the tree (option 2 below):ChatUiStatenow carries a lightweightstreamingId/streamingTextpair,appendToResponseonly appends to that string per token, and theconversationgetter overlays it onto the streaming node for real-time display. The tree map is left untouched during streaming and rewritten exactly once infinishStreaming(viaMessageTree.setContent), followed by the single existing persist. (Disk writes were already once-at-end —appendToResponsenever persisted — so only the in-memory copy needed fixing.) Real-time streaming output is preserved; only the latest message re-parses markdown since itskey-ed list item is the only one whose content changes.- Alternatives considered: (1) persistent map (
kotlinx.collections.immutable, order-preservingPersistentMap) — O(log N)putwith structural sharing, near drop-in forcopyOf, but still touches the tree per token; (3) coalesce/throttle tokens — fewer emissions but still O(N) per batch. Option 2 was chosen because it removes per-token tree churn entirely while matching the requirement that copying happen only once after the stream completes or is stopped.
- Alternatives considered: (1) persistent map (
-
Markdown re-parse on scroll — FIXED 2026-07-28, re-audited and reworked 2026-07-29.
MarkdownText(ui/markdown/Markdown.kt) re-parsed the entire message (AST build + reference-link lookup) on every recomposition. BecauseLazyColumndisposes off-screen items, scrolling a long chat re-parsed each bubble every time it re-entered the viewport. Resolved with a module-level access-ordered LRU (MarkdownParseCache) keyed by content string → parsedState.Success, rendered via the m3Markdown(state = …)overload. Styling (typography/padding/annotator) is still applied fresh at render, so appearance is unchanged; parsing is delegated to the library so reference-link resolution is preserved. The cache is a process-lifetime object, so it is cleared on conversation switch (clearMarkdownParseCache()fromChatScreen'sLaunchedEffect(state.root)) to release the previous chat's retained ASTs. Measured audit in §11.1 reclassified this as a scroll-smoothness fix rather than a battery fix and flagged the entry-count bound; §11.2 records the resulting rework (character-budget eviction, single-rememberparse viaparseMarkdown(), and a separate incremental path for the streaming bubble). -
getChildrenO(nodes²)/frame on scroll — FIXED 2026-07-28.ChatScreen'sitemslambda calledMessageTree.getChildren(an O(nodes)mappings.values.filter) per node to find siblings for branch arrows → O(nodes²) per recomposition in long chats. Replaced with a singleremember(state.mappings) { mappings.values.groupBy { it.parent } }grouping.state.mappingsis a new instance only on structural edits (edit/delete/regenerate) and reference-equal during streaming, so the grouping is correctly invalidated on tree changes and skipped (O(1)) during streaming.
- No read/idle timeout on the streaming socket.
OpenAiClient's streaming client setsreadTimeout(0)intentionally so a slow model isn't cut off. A stalled/half-open connection can keep the socket (and radio) awake until the user taps Stop; accepted as a deliberate trade-off for reliable long generations. (FIXED 2026-07-28: this now applies only to the streaming client. The non-streaming models GET previously shared the samereadTimeout(0)client and could hang forever on a half-open connection; it now uses a separate client with finitereadTimeout(5s)+callTimeout(5s). Connect timeout also cut 30s→5s.) - Streaming continues while backgrounded. The stream runs in
viewModelScope, not tied to UI visibility, so generation keeps running off-screen. Intentional — a reply should not be lost because the user briefly leaves the app. - Subnet scan burst.
EndpointScanner.scanForEndpointfires up toCONCURRENCY = 64concurrent probes across a /24 (≈254 hosts) and, on miss, an extended /21 (up to ~2046 hosts) at a 400 ms timeout — a short, intense radio + CPU spike. Intentional and bounded: triggered only by explicit user action (never periodically in the background), batched, and cancels remaining probes once a match is found. - Scrollbar
computeMetricsO(N)/frame — assessed, not changed (2026-07-28).DraggableScrollbar.computeMetricssums cached item sizes across all items (and the pre-thumb prefix) each frame while scrolling. This is cheap float/HashMaparithmetic (no parsing/allocation) and only runs during active scroll; even at extreme message counts the cost is sub-percent of a core. A true O(1) fix needs prefix-sum caching that stays consistent as sizes populate — disproportionate complexity/regression-risk for the carefully-tuned thumb geometry, so deferred until profiling shows it matters.
- No wake locks, foreground services, alarms, or
keepScreenOn. - No background polling, timers, or
while(true)loops; all coroutines are event- or stream-scoped. - Persistence is cheap: writes are coalesced through a
CONFLATEDchannel with a single consumer, andMessageRepositorywrites only the diff (skipping the DB entirely when nothing changed). - Scanner and streaming clients cancel their in-flight work correctly (
cancelChildren,EventSource.cancel,awaitClose). - The scrollbar fade uses a single idle
delaygated on scroll events, not a running animation loop.
Follow-up audit of the MarkdownParseCache change from §11, this time measured rather than reasoned about. Method: a throwaway JUnit harness against org.jetbrains:markdown:0.7.3 (the parser the renderer delegates to), run on the desktop JVM. Numbers are therefore JIT-warm x86-64; on-device ART/ARM is roughly 3–6× slower. Content samples are synthetic assistant replies (headings, prose, bullets, fenced code, blockquotes).
rememberMarkdownState(content, immediate = true) re-parses on every composition and recomposition, for two independent reasons:
state.parseBlocking()is called unconditionally in the composable body — there is noremember/LaunchedEffectguard around it.remember(input)can never hit either:Inputis adata class, but itsflavour/parser/referenceLinkHandlerdefaults are freshly allocated on each call and none of those types overrideequals.
So the original premise was correct. The mechanism differs from what §11 recorded, though: it is not primarily that LazyColumn disposes off-screen items — that only sets the frequency, because MarkdownText is otherwise skippable (String/Modifier/Boolean params are all stable).
| content | AST nodes | parse (desktop) | est. on-device |
|---|---|---|---|
| 406 ch | 88 | 0.15 ms | ~0.5–0.9 ms |
| 1,194 ch | 248 | 0.22 ms | ~0.7–1.3 ms |
| 3,164 ch | 648 | 0.59 ms | ~1.8–3.5 ms |
| 7,952 ch | 1,608 | 0.67 ms | ~2.0–4.0 ms |
Scroll re-entry only happens with the display on and a finger on the screen, where display + GPU draw ~two orders of magnitude more power than a few ms of parsing. The cache does not meaningfully move idle or reading battery.
It is still a worthwhile change, just under a different heading: 2–4 ms of avoided main-thread work is a real slice of an 8.3 ms frame budget at 120 Hz. Treat MarkdownParseCache as a scroll-smoothness optimisation.
Retained heap scales linearly with content — measured at ≈9.5 bytes of AST per content character, consistent across all four sample sizes.
| avg message size | 480 entries retain |
|---|---|
| 406 ch | 2.0 MB |
| 1,194 ch | 5.4 MB |
| 3,164 ch | 13.9 MB |
| 7,952 ch | 34.2 MB |
Because clearMarkdownParseCache() fires on every conversation switch, the working set is only the current chat (typically 10–60 messages). So the 480 cap is inert in the common case and only binds in exactly the pathological case where it permits 14–34 MB — no protection where it is needed, no benefit where it is not. Retaining tens of MB shrinks ART's GC headroom (more frequent concurrent GCs, which is real battery) and raises the odds of an LMK kill while backgrounded, whose cold restart costs more than every parse the cache will ever save.
Recommendation: re-bound by summed content characters rather than entry count — ~512 KB of content ≈ 5 MB retained, using the measured 9.5 B/char. (Adopted 2026-07-29 — see §11.2.)
- On a cache miss,
remember(markdown) { cache.get() }pinsnullfor that composable's lifetime, so the item keeps the re-parsing path even afterLaunchedEffectpopulates the cache. The benefit only lands on the next dispose/re-enter cycle. - The miss path adds a
collectAsState()subscription per uncached bubble that the hit path does not need. @Synchronizedon the cache is unnecessary (composition andLaunchedEffectboth run on the main thread), but harmless.- Content-string keying is sound:
String.hashCodeis memoized, and keys are usually the same instance held inmappings, soequalsshort-circuits on reference.
streamingText grows per chunk, so the streaming bubble gets a new content String per token → recomposition → unguarded parseBlocking() over the whole reply so far. Cumulative cost for a single response (desktop; multiply by 3–6 for device):
| final reply | tokens | cumulative parse | AST nodes allocated |
|---|---|---|---|
| 406 ch | 101 | 9.2 ms | 4,923 |
| 1,194 ch | 298 | 19.6 ms | 38,387 |
| 3,164 ch | 790 | 96.6 ms | 259,628 |
| 7,952 ch | 1,987 | 449.8 ms | 1.61 M |
Cleanly quadratic (2.5× content → 4.7× cost). MessageItem passed cache = !(busy && isLatest), so this path was deliberately excluded from the cache.
Disposition at time of audit: known and accepted. The cache was scoped on purpose to idle/reading use — the concern was a user scrolling back through a long conversation, not generation. Streaming is bounded (it ends when the reply ends), screen-on, and already the moment the user expects the device to be working. It was nevertheless the largest remaining CPU/allocation item in the UI, and it retracts the §1.2 claim that this RN defect does not exist natively. Levers, cheapest first:
- Coalesce/sample token updates (
Flow.sample(~40–60 ms)on the render path only, leavingstreamingTextaccumulation untouched). No dependency change; caps re-parses at ~20/s instead of ~30–60/s. StreamingMarkdownState— see below. The actual fix.
Superseded 2026-07-29: lever 2 was implemented (§11.2), which removes the quadratic term outright rather than reducing its constant. Lever 1 was not needed.
Checked 2026-07-29 at the user's suggestion. What it is: an append-only incremental parser built on org.intellij.markdown.parser.StreamingMarkdownFile / EmptyStreamingMarkdownFile, new in org.jetbrains:markdown 0.7.5 (the renderer bumped 0.7.3 → 0.7.5 in that PR specifically to get it). It keeps a StringBuilder of accumulated content and exposes a Snapshot(stableAst, unstableAstTail); append(chunk) re-parses only the trailing unfinished block, making a full response O(n) instead of O(n²). Stable node identity is preserved across appends (the library's own tests assert assertSame on a completed paragraph), so Compose can skip re-rendering already-finalised blocks — a second win on top of the parsing one. API surface: rememberStreamingMarkdownState(), Flow<String>.collectAsStreamingMarkdownState(), and an m3 Markdown(streamingMarkdownState = …) overload. The same PR deprecates Flow<String>.asMarkdownState() with precisely our diagnosis: "reparses every emitted String and is not suitable for streaming content."
Verdict: right tool, different problem. It targets the streaming path (§ above), not the idle/scrolling path that motivated MarkdownParseCache:
rememberStreamingMarkdownStateis aremember, so scrolling a finished message out of theLazyColumnand back still destroys and rebuilds it. It has no cross-disposal persistence.- It cannot be seeded with existing content —
append(chunk)is the only mutator. Callingappend(wholeMessage)once degenerates to an ordinary full parse. - Its
SnapshotholdsList<ASTNode>for the finished message, i.e. the same retained footprint as the current cache. No memory advantage either.
Also required on our side: our streaming text lives in ChatViewModel as an accumulated streamingText: String in ChatUiState, not as a Flow<String> of deltas at the UI layer. Using collectAsStreamingMarkdownState means exposing the chunk flow to the composable and handling branch switching, regeneration and process death against an append-only object. That is an architectural change, not a drop-in. (Resolved 2026-07-29: the chunk flow was avoided entirely by deriving deltas from the accumulated text — §11.2, decision 2.)
Blocked on the same toolchain bump as everything else (Kotlin 2.3.x metadata — see §10 Dependencies / tech debt). Until then, lever 1 (sampling) is the only option, and it is deliberately not taken. (Unblocked and adopted 2026-07-29; the real Kotlin floor turned out to be 2.4 — see §11.2.)
Caveats if/when we do adopt it (feature is young — merged and released June): the default StreamingMarkdownSuccess renders into a plain Column iterating all stableAst nodes with no key(…), so slot reuse across tail changes is unguarded; the LazyMarkdownSuccess variant keys on startOffset alone (collision risk); and MarkdownElementInternal keys its remember on the mutable StringBuilder by reference, so the remembered model can go stale. All three were raised in automated review on the PR and left as-is.
Implementation of the §11.1 recommendations, unblocked by the toolchain bump recorded in §3. Two separate problems, now on two separate code paths.
The parse now happens inside a single remember(markdown), using 0.43.0's synchronous top-level parseMarkdown():
val state = remember(markdown) {
MarkdownParseCache.get(markdown)
?: parseMarkdown(markdown).also {
if (it is State.Success) MarkdownParseCache.put(markdown, it)
}
}This replaces the rememberMarkdownState(immediate = true) + collectAsState() + LaunchedEffect arrangement and resolves all three "minor findings" from §11.1 at once: there is no longer a miss branch that pins null for the composable's lifetime, no extra collectAsState() subscription on uncached bubbles, and the hit and miss paths are the same expression.
The cache: Boolean parameter is gone. It existed solely to keep the streaming bubble out of the cache; the streaming bubble no longer routes through MarkdownText at all.
MAX_ENTRIES = 480 → MAX_CHARS = 512 * 1024, with eviction driven by a running sum of key lengths. At the measured ~9.5 B of AST per content character that is ~5 MB retained, and it holds regardless of message size — which was the whole problem with the entry count.
Eviction is an explicit loop in put rather than removeEldestEntry, because the latter evicts at most one entry per insertion and cannot recover when a single large message pushes the total well past the budget. The loop stops at size > 1, so the entry just inserted is never evicted even if it alone exceeds the budget — it is the one being rendered.
The actively streaming bubble now renders from an append-only StreamingMarkdownState, so each token re-parses only the trailing unfinished block: O(n) per response instead of O(n²), eliminating the 450 ms / 1.6 M-node worst case in §11.1. Stable nodes keep their identity across appends, so Compose also skips re-rendering finalised blocks.
Two integration decisions worth recording, both addressing §11.1's objections:
-
The state is hoisted above the
LazyColumn, inChatScreen, keyed bystate.streamingId. §11.1 correctly noted thatrememberStreamingMarkdownStatedies with its composable and the parser cannot be re-seeded, which would be fatal if it lived inside the list item —LazyColumndisposes items that scroll out of view. Hoisting sidesteps that entirely; keying onstreamingIddiscards the state on a new stream, branch switch, or regeneration. -
Deltas are derived from the accumulated text, not from a chunk flow.
ChatUiState.streamingTextremains the single source of truth;rememberChatStreamingMarkdownStatetracks how much it has already appended and feeds the parsertext.substring(appended)from inside asnapshotFlow { … }.collect { … }. This avoids exposing aSharedFlowof chunks to the UI (which would drop tokens emitted before collection starts) and makes conflation safe by construction:snapshotFlowconflates, so a burst of tokens arriving faster than the parser simply collapses into one larger delta. Correctness does not depend on observing every intermediate value — which is exactly why the §11.1 lever-1 throttle became unnecessary.
When a stream completes, streamingId clears and the bubble falls back to MarkdownText, which parses the finished reply once and populates the LRU. That single full parse is the cost of the handoff and is what makes the message a cache hit for all subsequent scrolling.
The three upstream quality issues listed at the end of §11.1 are still present in 0.43.0 and we are now exposed to them. None is a correctness problem for our usage as far as we can tell — the keyless Column and the by-reference StringBuilder remember key both behave because stable nodes have fixed offsets and the unstable tail is rebuilt on each append — but they are the first place to look if streaming rendering ever misbehaves.
:app:assembleDebug, :app:assembleRelease (R8 + resource shrinking) and :app:testDebugUnitTest all pass on the new toolchain. Remaining compile warnings are pre-existing deprecations unrelated to this work (LocalClipboardManager, non-auto-mirrored KeyboardArrowLeft/Right).
Signed release APK (arm64-v8a) grew ≈1.3 MB → ≈2.0 MB, attributable to the Compose 1.7.6 → 1.10.x and renderer 0.33.0 → 0.43.0 jumps. Accepted: still an order of magnitude under the ~20 MB React Native build and well inside the ~5–8 MB target in §1.
Verified on-device 2026-07-29 on two handsets, Android 11 (API 30) and Android 16, spanning the pre- and post-StreamingMarkdownState platform range that matters to us — API 30 also exercises the non-platform-splash path noted in §4.5. No streaming-render regressions observed, so the upstream caveats above have not bitten in practice.