Skip to content

fix(android): keep chat composer above the keyboard (#194, #156) - #236

Merged
dzianisv merged 1 commit into
mainfrom
fix/keyboard-ime-194
Nov 13, 2026
Merged

dzianisv merged 1 commit into
mainfrom
fix/keyboard-ime-194

Conversation

@dzianisv

Copy link
Copy Markdown
Owner

Fixes #194
Fixes #156

What

Re-implementation (from scratch, per the Stage 1 context pack) of the Android keyboard fix — no candidate PR merged as-is:

  • Base mechanism from fix(android): keep composer synchronized with IME #201 (head 0bb9af0): live IME height via Keyboard.metrics() + keyboardDidShow/keyboardDidHide listeners, applied as paddingBottom, explicitly reset to 0 on hide (src/lib/keyboard-offset.ts + test, session-screen wiring).
  • (b) coverage from fix(android): keep IME prompts usable #208 (head 95b001d): bounded multiline composer (composerMaxHeight from live IME geometry, internal scroll via scrollEnabled) + scrollable QuestionPrompt with pinned footer (src/lib/session-layout.ts + test, composer cap, QuestionPrompt.tsx).

API-35 decision (the mandatory design point)

JS padding applies only below API 35; the native IME-insets listener owns API 35+. MainActivity.kt / plugins/withKeyboardInsets.js install a native listener gated on SDK_INT >= VANILLA_ICE_CREAM that adds the IME height to the root view's bottom padding. The new jsKeyboardPaddingApplies(platform, apiLevel) helper gates the JS padding to Platform.Version < 35, so exactly one layer applies the IME height on every API level — no double-padding. Chosen over removing/extending the native plugin because the native fix is the working, shipped behavior on 15+; the JS gate is minimal and reversible.

Deliberate non-ports

Diff

6 files, +217/−14 (vs main e8444931):

  • app/session/[id].tsx (+50/−12): keyboard listeners, JS padding, composer cap
  • src/components/chat/QuestionPrompt.tsx (+9/−7): scrollable body, pinned footer
  • src/lib/keyboard-offset.ts (new) + src/lib/keyboard-offset.test.ts (new, 10 tests)
  • src/lib/session-layout.ts (new) + src/lib/session-layout.test.ts (new, 5 tests)

Connection add/edit screens need no change: already KAV-wrapped on main (merged #216).

Gate evidence (on head 484e227, this VM — no Android SDK/device)

  • npm run check:versions — exit 0
  • npm run typecheck — exit 0
  • npm test (full suite) — 395 pass, 0 fail (baseline on main: 380 pass — +15 tests, test files 48 → 50; 2 ported offset tests from fix(android): keep IME prompts usable #208 dropped, see above)
  • No new dependencies; applicationId untouched (cc.agentlabs.opencode).

Open residual (Stage 5)

Physical-device proof (open / multiline / dismiss) was not run — this VM has no Android SDK or device. Needs a real Android phone before merge, including the API-35 vs pre-35 check that exactly one layer applies the IME height.

Port of the live-IME-height mechanism from #201 (Keyboard.metrics() +
keyboardDidShow/Hide listeners, explicit reset to 0 on hide) plus the
bounded multiline composer and scrollable QuestionPrompt from #208.

API-35 guard: the native IME-insets listener in MainActivity owns the
IME height on Android 15+, so the JS padding applies only below API 35 —
exactly one layer applies it on every API level. iOS keeps
behavior="padding" with offset 90.

#208's static insets.top offset is deliberately not ported: it measured
56 dp short on a Pixel 11 Pro (see #194).
@dzianisv

Copy link
Copy Markdown
Owner Author

Stage 4 review — PASS

Independent review of head 484e227d305ae76bfaca1be94f2d34a4965ed81d (fix/keyboard-ime-194, 6 files +217/−14). I read the full diff, src/lib/keyboard-offset.ts, src/lib/session-layout.ts, app/session/[id].tsx (in situ), QuestionPrompt.tsx, MainActivity.kt, plugins/withKeyboardInsets.js, the context pack (issue #194 comment 6855205733), and issues #194/#156. No validated defects found; no FAIL findings.

Acceptance criteria

  • (a) Composer above keyboard on open — Android applies the IME-reported height as paddingBottom via keyboardDidShow/Keyboard.metrics() (app/session/[id].tsx:99-130, 665-681). This is fix(android): keep composer synchronized with IME #201's mechanism, which passed a full open/close cycle on a physical Pixel 11 Pro (Android: chat input hidden behind keyboard #194 comments); fix(android): keep chat composer above the keyboard (#194, #156) #236's session code is that mechanism plus keyboardY tracking and the API-35 gate.
  • (b) Multiline typing — composer maxHeight cap (composerMaxHeight, 88–120 dp, font-scale aware) + scrollEnabled on the TextInput; QuestionPrompt body is a ScrollView with pinned footer. The QuestionPrompt.tsx patch is byte-identical to fix(android): keep IME prompts usable #208's, which passed QA on a physical Pixel 8 Pro.
  • (c) Returns to bottom on dismiss — keyboardDidHide explicitly resets height to 0 and keyboardY to null; Android KAV behavior={undefined} + offset 0, so no stale padding path remains (the RN 0.81 stale-offset bug cited in the code is avoided by construction).
  • (d) Connection add/edit — unchanged by this PR, KAV-wrapped scrollable forms (app/connection/add.tsx, app/connection/[id].tsx, via fix: handle PermissionV2 events and keyboard avoidance on connection screens #216).
  • (e) No iOS regression — iOS path is byte-identical in behavior: behavior="padding", offset 90 (keyboardVerticalOffset returns IOS_KEYBOARD_VERTICAL_OFFSET for iOS regardless of insets; tested).

API-35 guard — exactly one layer applies IME height on every API level (verified)

  • MainActivity.kt:28 / plugins/withKeyboardInsets.js (wired in app.json:51): native IME-insets listener gated on SDK_INT >= VANILLA_ICE_CREAM (API 35).
  • jsKeyboardPaddingApplies() (src/lib/keyboard-offset.ts:28-32): true only for Android with numeric Platform.Version < 35; false on iOS, on API 35+, and on non-numeric versions (fail-safe off, so no double-apply). JS padding is applied only through this gate ([id].tsx:123-126).

Test-count delta — verified by my own runs

No scope creep

No applicationId change, no new dependencies, no manifest/gradle/package.json changes — the diff touches only the 6 listed files.

Merge gate — NOT green yet, do not merge on this verdict alone

Branch protection on main (strict) requires exactly: "Typecheck and unit tests" ✅ pass, "test" ✅ pass, "build" ⏳ pending. mergeable: true, mergeable_state: blocked (required build pending). CUA is not a required check: cua-smoke.yml is absent (deleted in e8444931); the checkout AGENTS.md "MANDATORY CUA before merge" text is stale — the actual gate is the three checks above.
Stage 5 gates before merge: (1) build green on this head; (2) physical-device proof (open/multiline/dismiss on a real Android phone, API <35 and 35+), which no run in this review could do — no Android SDK/device on this VM.

Filed notes (not failures, out of this PR's composer + connection-forms scope)

  • app/(tabs)/index.tsx:599 (New Session modal) and :819 (Rename modal, autoFocus TextInput) still use KeyboardAvoidingView behavior="height" on Android — same KAV-on-Android class, untouched by this PR; candidates for a follow-up task.
  • Connection forms on API <35 still rely on KAV behavior="padding" (offset 0) — mitigated by their scrollable ScrollView forms; worth a device check alongside (2) above.
  • One correction to the context pack: it says "2 dropped tests" — only one test's assertions were abandoned (the static Android offset test); the iOS-offset test was moved, not dropped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android: chat input hidden behind keyboard Unable to see the input box

1 participant