Skip to content

feat(app): dismiss the chat keyboard on a fast upward flick#2417

Open
nllptrx wants to merge 1 commit into
getpaseo:mainfrom
nllptrx:feat/chat-keyboard-flick-dismiss
Open

feat(app): dismiss the chat keyboard on a fast upward flick#2417
nllptrx wants to merge 1 commit into
getpaseo:mainfrom
nllptrx:feat/chat-keyboard-flick-dismiss

Conversation

@nllptrx

@nllptrx nllptrx commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Linked issue

Closes #2415

Type of change

  • Bug fix
  • New feature (with prior issue + design alignment)
  • Refactor / code improvement
  • Docs

What does this PR do

Scrolling the chat history back to read earlier messages left the keyboard up, so the transcript stayed squeezed into the space above it and the keyboard had to be closed by hand first. Two gestures for what should be one.

Now a fast upward flick over the history dismisses it, while a slow, or fast but controlled, read-scroll leaves it alone.

React Native's own keyboardDismissMode cannot express that: "on-drag" fires on the first pixel and kills the keyboard on any peek-scroll, and "interactive" is broken on inverted lists (the gesture directions are reversed, so it needs a long drag and feels janky). So the gesture is measured here instead: samples are taken from the scroll events, and at release the speed over the drag's final stretch decides. Measuring at release rather than averaging the whole drag is what separates a flic, finger lifted mid-motio, from a fast but controlled scroll, which decelerates before the finger lifts.

Two details worth calling out, both of which were wrong in earlier revisions:

  • Timing comes from nativeEvent.timestamp, the native gesture time every platform stamps on the payload, not from a clock and not from the synthetic timeStamp. The synthetic one reads a differently spelled key, so it silently falls back to the dispatch clock, and with a busy JS thread the callbacks arrive bunched together, which reads a calm scroll as a flick.
  • The release offset comes from the end-drag event itself, because the last onScroll can be stale by the time a short flick lands.

Dismissal blurs the input and dismisses the keyboard, and only runs when a software keyboard actually occupies space. On Android, dismissing alone leaves the input focused with the keyboard inset still applied, so the layout stays shifted with an empty gap where the keyboard was; blurring alone releases focus but leaves the IME on screen. The occupies-space gate matters for hardware keyboards: the composer can be focused with no on-screen keyboard, and blurring there would stop the user typing to dismiss something that was never visible.

The speed logic lives in scroll-keyboard-dismiss.ts as pure functions so it can be unit tested.

How did you verify it

Behaviour on a physical Android device (Xiaomi 24069PC21G, Android 16), Release build, gestures driven with adb shell input swipe at fixed durations over 233 dp (480 dpi, so 1 dp = 3 px):

Gesture Measured speed Result
1500 ms drag 0.16 dp/ms keyboard stays up
400 ms drag 0.58 dp/ms keyboard stays up
80 ms flick 2.9 dp/ms keyboard dismisses, composer settles at the bottom, no leftover gap

Screenshots for the three runs are attached below. I also confirmed the Release build regenerated index.android.bundle for each run rather than reusing a cached one, and that the earlier "empty gap after dismissal" state does not reproduce.

Unit tests: npx vitest run src/agent-stream/scroll-keyboard-dismiss.test.ts src/hooks/use-keyboard-shift-style.test.ts --project unit, 14 cases pass. They pin the behaviours that broke during development: a slow read-scroll staying below the threshold, a flick crossing it, a fast-but-decelerating drag that would pass on a whole-drag average but not at release, a drag toward newer messages staying negative, the fallback for gestures too short to sample, stepping back a sample when the release lands right after one, delayed and bunched callbacks not becoming a flick, and the native timestamp being preferred over the dispatch clock.

npm run typecheck, npm run lint and npm run format:check all pass at the repo root.

See Android

-5808126259059367132.mp4

See IOS

ScreenRecording_07-24-2026.23-52-01_1.1.mp4

Not affected, so not tested: web and desktop. strategy-native.tsx is the native render strategy only; the web strategy is a separate module.

Risk surface

Known limitation: the speed is derived from scroll offset, so a flick performed while the offset cannot move, the list already at the oldest edge, or a transcript too short to scroll, is not detected and leaves the keyboard up. On iOS the rubber-band still moves the offset, so in practice this is an Android edge case. Both platforms do expose a native velocity on the end-drag payload, so a follow-up could cover it, but the sign and unit conventions differ per platform and need device verification.

The threshold (1.5 points/ms at release) is a single constant, calibrated on one Android device and one iPhone.

Checklist

  • One focused change. Unrelated cleanups split out.
  • npm run typecheck passes
  • npm run lint passes
  • npm run format ran (Biome)
  • UI changes include screenshots or video for every affected platform
  • Tests added or updated where it made sense

Scrolling the history to read earlier messages left the keyboard up, so the
visible transcript stayed cramped and the keyboard had to be closed by hand
first — two steps for what should be one.

React Native's keyboardDismissMode cannot express the wanted behaviour:
"on-drag" fires on the first pixel and kills the keyboard on any peek-scroll,
and "interactive" is broken on inverted lists. So the gesture is measured
here: samples are taken from the scroll events and, at release, the speed over
the drag's final stretch decides. Measuring at release rather than averaging
the whole drag is what keeps a fast but controlled read-scroll from counting
as a flick, since such a gesture decelerates before the finger lifts.

Timestamps and offsets come from the events, never from a clock: with a busy
JS thread the callbacks arrive in a burst long after the gesture, and
wall-clock spacing then reads a calm scroll as a flick. The release offset
comes from the end-drag event for the same reason — the last onScroll can be
stale by the time a short flick lands.

Android needs both the blur and the dismiss. Dismissing alone leaves the
input focused and the keyboard inset applied, so the layout stays shifted
with an empty gap where the keyboard was; blurring alone releases focus but
leaves the IME on screen.

Verified on an Android device with a Release build: a slow drag and a
0.6 dp/ms scroll keep the keyboard, a 2.9 dp/ms flick dismisses it and the
composer settles back with no leftover gap. iOS was exercised by hand on
device only, without automated coverage of the gesture itself.
@nllptrx
nllptrx force-pushed the feat/chat-keyboard-flick-dismiss branch from c28b126 to 0844156 Compare July 24, 2026 22:43
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.

feat: dismiss the chat keyboard when flicking the history up

1 participant