Commit Graph
5 Commits
Author SHA1 Message Date
Jinwoo Hong e9ec63168f fix(mobile): hold-to-dictate, repeat keys and the browser long-press survive the page's long-press (#24277)
On the OTA page a held press died ~500 ms in: the WebView's long-press selected nearby text and that selection's selectionchange/touchcancel ended the press. Page text is now unselectable unless it opts in (as native), hold surfaces declare onLongPress, the browser pane refuses contextmenu termination, and the chat mic's swapped icons no longer steal the touch target.
2026-10-01 01:18:24 -04:00
Jinwoo Hong ba858ee446 feat(mobile): the keyboard covers the page like a native screen, and the shell says its height (#23110)
The shell no longer shortens the WebView for the keyboard; it publishes the keyboard height like the safe-area insets, so native's keyboard lift, refit hold and dismiss key run on the page unchanged. Keyboard and inset arithmetic read the shell's OS through a host-os seam. One page-version floor (manifest pageVersion, shell floor 1) replaces per-feature accept negotiation; a page below the floor gets the existing update wall, a desktop with no bundle keeps native screens. iOS shell drops the form accessory bar and its own keyboard observers. Native session screens untouched.
2026-09-28 04:07:35 -04:00
Jinwoo Hong 564f135248 fix(mobile): an accessory Enter ends the field's editing session, and the page's Enter survives a composition (#22300)
* test(config): the page's live input never submits under an open composition

The emulator's page-only defect, in a browser: an Android soft keyboard keeps a
composition open over the word being typed, so the Enter keydown carries
`isComposing: true`, which is the condition react-native-web reads to skip
`onSubmitEditing` entirely. Nothing reaches the terminal and the field keeps the
text. The probe route now mounts `useTerminalLiveInputCommit` and the command
dock's own field props, so keys enter through the browser rather than through a
handle that calls the hook directly.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(config): format the live-input render check

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): the page's live input submits under an open composition

react-native-web's keydown handler withholds `onSubmitEditing` whenever the
Enter keydown reports a composition — `nativeEvent.isComposing`, or the Android
`keyCode` 229 that stands for it — which is a soft keyboard's normal state
mid-word. Nothing reached the terminal and the field kept the text. Native
Android's editor action has no such suppression, which is why only the page
showed it.

The field now also claims `beforeinput`/`insertLineBreak`, the browser's own
end-of-line signal. react-native-web cancels every keydown it does submit on,
so that event exists only in the cases it dropped, never twice; an IME still
choosing a candidate reports `insertCompositionText` and is left alone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): list the live-input submit binding, and repin the session closure

The `.web.ts` sibling needed its row in `web-overrides.json`, whose check lists
exactly the overrides on disk. The session route's page closure moves with it:
the callback ref and the binding it resolves to are both local, and the native
sibling stays out, which is what the pair is for.

  modules        4207 -> 4209   (+2)
  local modules  1021 -> 1023   (+2)

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the key bar's Enter chip leaves the sent text in the live field

The device trace's variant (a), which is what shots/23 was: the chip emits no
DOM key event, so react-native-web's submit handling never runs and the
accessory hook is the only thing that could end the field's editing session.
With nothing held it takes the send-now branch, which flushes nothing and
writes neither the capture state nor the field, so the text the PTY already
echoed stays put and the next keystrokes append to it.

Not a page defect: the held-text fallback only holds a trailing non-ASCII run,
so ASCII leaves nothing held on native either. The unit case is on the shared
hook for that reason.

The probe route now models what the send actions do with 'allow-raw', so the
check can see a control sent twice or not at all.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): the buffered command field has the live field's Enter gate too

Three cases, one red. A plain Enter sends the draft and empties the field, which
is `beginBufferedTerminalDraftSend`'s doing and stays a guard. The key bar's
Enter chip in buffered mode is a plain terminal key: the accessory hook declines
at its live-handle guard, one carriage return goes out and the draft is
untouched, also a guard.

The red one is Enter under an open composition. This field reaches its send
through `onSubmitEditing` alone, so react-native-web's keydown gate swallows it
exactly as it did for the live field, and the draft neither goes out nor leaves
the field.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): an accessory Enter ends the field's editing session, on both fields

Two defects, one rule: after a control that ends the line, the terminal owns
the echoed text and the field's editing session is over.

The key bar's Enter chip (device trace, variant (a)) emits no DOM key event, so
only the accessory hook could end that session. Its held-text branch does,
through the flush; with nothing held it took `send-now`, which flushed nothing
and wrote neither the capture state nor the field. Not page-only: the held-text
fallback holds a trailing non-ASCII run, so ASCII leaves nothing held on native
either. `send-now` now takes the same flush when the bytes end the line, and
still defers the send to its caller so exactly one return goes out.

The buffered command field had the live field's composition gate, because it
also reaches its send through `onSubmitEditing` alone. It binds the page's
line-break signal now too, which is why the seam is named for a terminal text
field rather than for the live input.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the submit binding holds the handler its first render was given

Found by pullfrog on #22300. The binding refreshed its handler ref only when
the callback identity changed, so a caller memoizing on an empty dependency
list was bound once and never again. The buffered command field does exactly
that: its submit closes over handleSend, a per-render function whose guard
reads client and activeHandle, both null until effects supply them, so the
page's line-break submit could never pass that guard.

The probe route now carries the same two paths the dock has — a fresh
per-render function on the field's onSubmitEditing prop, and the memoized
closure on the binding — because the working prop path is what hid this.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): the buffered field's page submit reaches a live handleSend

Found by pullfrog on #22300. `submitBufferedDraft` was memoized on an empty
dependency list, which froze the per-render `handleSend` it calls. That guard
reads `client`, `activeHandle` and `canSend`, none of which the first render
has, so the page's line-break submit could never pass it. The field's own
`onSubmitEditing` prop kept working, which is what hid it.

The handler is per-render now, and the binding refreshes its handler ref on
every commit rather than when the callback identity changes — the ref exists so
the listener always reaches the newest handler, and it should not rest on a
caller's memoization. That second half fixes nothing on its own: a `useCallback`
with `[]` returns one function object for the life of the component, so no ref
can find a newer closure behind it. The source census is what catches that, and
it is red against the frozen handler.

The probe route is back on the dock's shape, per-render on both submit paths,
with a note saying why a route that writes its own submit cannot catch this.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the page's buffered submit reaches a handleSend that can send

The census beside this reads one spelling of a frozen handler. This reads the
behaviour: the send-actions hook is rendered first as a session is before its
effects land, with no client and no handle, then again as it is after, and the
listener the page's binding attached has to reach the second one. Asserted on
the params that reach the client, not on a call count.

Red with the `useCallback` restored, and red with a `useMemo` in its place,
which is the point of testing the behaviour rather than the spelling.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): declare the two mock arrays instead of asserting them

`[] as Array<T>` inside the hoisted factory was a type assertion with nothing to
explain: the arrays are built here, so a checked declaration says the same thing
and the quality gate has nothing to flag.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): the submit binding stops claiming a cure it does not have

The handler ref refreshed on every commit rather than on the callback identity,
and the docblock called that the reason the seam exists. It is not: a caller
that freezes its closure hands this hook one function object for the life of the
component, so no ref finds a newer one, and a caller that does not freeze it
changes identity every render and refreshes the dependency anyway. Measured both
ways. The dependency is back, and the prose says only what the code does.

The rule that does hold — a bound handler must not be frozen on an empty
dependency list — is stated where it is enforced, in the wiring census, with the
behavioural check named beside it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): the submit binding's latest-ref drops its dependency list

React Doctor flags the dependency twice, once per bound field: both of the
dock's field submits are rebuilt every render, because the handleSend they read
is, so `[onSubmit]` is a new value every time and there is nothing to compare.
The tree's other latest-refs are written without a list for the same reason —
use-mobile-web-shell-bridge.ts:148 is the one this follows.

The comment says what the ref does, which is mirror the newest closure after
each commit so callers may pass per-render handlers. It claims nothing about a
caller that freezes one; that rule is still the wiring census's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 15:55:36 -04:00
Neil cb94265cf4 fix(mobile): restore session parity after extraction
(cherry picked from commit 47706a5388)
2026-08-31 23:35:08 -07:00
Neil 7caf7ab1e1 refactor(mobile): split session and terminal surfaces
(cherry picked from commit f129f2926a)
2026-08-31 23:35:08 -07:00