fix(app): stabilize native composer sizing after edits (#4946)

* fix(app): stabilize native composer sizing after edits

* test(app): verify workspace composer space after keyboard dismissal

* test(app): start the hold-to-delete regression mid-draft

From the end of the draft the pre-fix input also returns to baseline. Placing the cursor
eight lines into the draft before the first hold-delete leaves the old build at the
capped height with the placeholder visible, and the fixed build at one line.
This commit is contained in:
Mohamed Boudra
2026-09-16 15:51:22 +02:00
committed by GitHub
parent a78949d1d4
commit 0e8a71a5fe
11 changed files with 137 additions and 41 deletions
@@ -17,6 +17,7 @@ DAEMON_HOME="${PASEO_COMPOSER_KEYBOARD_DAEMON_HOME:-${REPO_ROOT}/.dev/composer-e
SERVER_ID="${PASEO_COMPOSER_KEYBOARD_SERVER_ID:-}"
MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four'
LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')"
BLANK_LINE_DRAFT="$(node -e 'process.stdout.write("\n".repeat(22) + "ddjdj")')"
AGENT_TITLE="Keyboard dismiss QA $(date +%s)"
if [[ -z "${SERVER_ID}" ]]; then
@@ -280,6 +281,35 @@ node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to
# its baseline height. Fabric only re-measures the Android input when its text prop changes,
# so the editing input renders again after every edit
# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the
# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the
# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from
# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key
# row on the paseo-api35 layout.
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
open_gboard "${input_x}" "${input_y}"
adb shell input keycombination 113 122
adb shell input keyevent 20 20 20 20 20 20 20 20
screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)"
backspace_x="$((screen_width - 85))"
backspace_y="$(($(read_ime_top) + 507))"
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000
adb shell input keycombination 113 123
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'label="Message, @files, /commands"' 10000
snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-hold-delete.json"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
@@ -362,4 +392,13 @@ node "${ASSERT}" xml-composer-contained \
"$(read_ime_top)" \
"${display_density}"
adb shell input keyevent BACK
wait_for_ime false
adb shell ime set "${HELPER_IME}" >/dev/null
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.json"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/new-workspace-long-draft.json" \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.json"
echo "Composer keyboard invariants passed"
@@ -103,7 +103,9 @@ if (command === "xml-composer-contained") {
readInputHeight(firstPath),
readInputHeight(secondPath),
]);
if (firstHeight !== secondHeight) {
// Yoga rounds each edge to physical pixels; moving the same-sized input can
// change its reported height by one pixel when its origin is fractional.
if (Math.abs(firstHeight - secondHeight) > 1) {
throw new Error(`Composer height drifted from ${firstHeight} to ${secondHeight}`);
}
} else if (command === "has-exact-text") {
@@ -1,4 +1,11 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react";
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useRef,
useReducer,
useState,
} from "react";
import { TextInput } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import PasteInput, {
@@ -46,6 +53,12 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
// once the view is attached.
const isAwaitingReplacementRef = useRef(false);
const [replacement, setReplacement] = useState({ revision: 0, autoFocus: false });
// Fabric re-measures the Android input when its `text` prop changes. A native edit only
// refreshes a cached spannable, and batched IME deletes (Gboard hold-to-delete) leave the
// measured height at the previous content, so an emptied draft keeps several lines. Render
// again after every edit so `defaultValue` carries the current text and the input is
// re-measured. Only this leaf renders; the composer stays isolated from typing.
const [, bumpTextRevision] = useReducer((revision: number) => revision + 1, 0);
const assignInputRef = useCallback((input: NativeInput | null) => {
inputRef.current = input;
@@ -107,6 +120,7 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
(nextText: string) => {
textRef.current = nextText;
onChangeText?.(nextText);
bumpTextRevision();
},
[onChangeText],
);
@@ -630,7 +630,7 @@ export function WorkspaceDraftAgentTab({
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
);
return (
<ComposerViewport style={styles.container} bottomInset={insets.bottom}>
<ComposerViewport style={styles.container} bottomInset={insets.bottom} keyboardReserve="retain">
<FileDropZone style={styles.container}>
<View style={styles.contentContainer}>
{isSubmitting && draftAgent ? (
+5 -1
View File
@@ -1961,9 +1961,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.content,
fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.content * 1.4,
// No lineHeight on native. React Native applies it as a span over the text, and an
// empty trailing line is laid out from the font's own metrics on some devices, so
// the input jumps when the first character lands on a new line. The font's natural
// line box is the same for every line. Web keeps the CSS value.
...(isWeb
? ({
lineHeight: theme.fontSize.content * 1.4,
outlineStyle: "none",
outlineWidth: 0,
outlineColor: "transparent",
@@ -2,37 +2,47 @@ import { describe, expect, it } from "vitest";
import { resolveComposerCapacity, updateComposerCapacity } from "./capacity";
describe("composer viewport", () => {
it("preserves the editing capacity when the keyboard closes", () => {
it("preserves the editing capacity when the keyboard closes on a docked composer", () => {
const viewport = { height: 582, bottomInset: 24, centered: false };
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }, "retain");
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 }, "retain");
expect(open.capacity).toBe(245);
expect(closed.capacity).toBe(open.capacity);
expect(updateComposerCapacity(closed, { ...viewport, keyboardShift: 250 }).capacity).toBe(303);
expect(
updateComposerCapacity(closed, { ...viewport, keyboardShift: 250 }, "retain").capacity,
).toBe(303);
});
it("releases the whole viewport when the keyboard closes on a form", () => {
const viewport = { height: 582, bottomInset: 24, centered: false };
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }, "release");
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 }, "release");
expect(open.capacity).toBe(245);
expect(closed.capacity).toBe(553);
expect(
updateComposerCapacity(closed, { ...viewport, keyboardShift: 308 }, "release").capacity,
).toBe(245);
});
it("remeasures the viewport without forgetting the keyboard reservation", () => {
const open = updateComposerCapacity(undefined, {
height: 582,
bottomInset: 24,
centered: false,
keyboardShift: 308,
});
const open = updateComposerCapacity(
undefined,
{ height: 582, bottomInset: 24, centered: false, keyboardShift: 308 },
"retain",
);
expect(
updateComposerCapacity(open, {
height: 650,
bottomInset: 24,
centered: false,
keyboardShift: 0,
}).capacity,
updateComposerCapacity(
open,
{ height: 650, bottomInset: 24, centered: false, keyboardShift: 0 },
"retain",
).capacity,
).toBe(313);
expect(
updateComposerCapacity(open, {
height: 0,
bottomInset: 24,
centered: false,
keyboardShift: 0,
}),
updateComposerCapacity(
open,
{ height: 0, bottomInset: 24, centered: false, keyboardShift: 0 },
"retain",
),
).toEqual(open);
});
it("leaves five points below the header for a bottom-anchored composer", () => {
+13 -3
View File
@@ -10,6 +10,14 @@ export interface ComposerCapacity {
capacity: number;
}
/**
* What the closed keyboard leaves behind. A docked chat composer retains its last
* editing reservation so a capped draft does not expand over the stream. A form
* that scrolls inside the constrained space releases it so the whole form is
* visible again once the keyboard is gone.
*/
export type KeyboardReservePolicy = "retain" | "release";
export function resolveComposerCapacity(input: ComposerGeometry): number {
"worklet";
// A centered form grows upward by half its height. Reserve both halves so
@@ -22,13 +30,15 @@ export function resolveComposerCapacity(input: ComposerGeometry): number {
export function updateComposerCapacity(
previous: ComposerCapacity | undefined,
input: ComposerGeometry,
policy: KeyboardReservePolicy,
): ComposerCapacity {
"worklet";
if (input.height <= 0 && previous) return previous;
// Closing the keyboard moves the composer; it does not enlarge its editing
// capacity. A subsequent keyboard opening supplies the next reservation.
// A subsequent keyboard opening always supplies the next reservation.
const keyboardReserve =
input.keyboardShift > 0 ? input.keyboardShift : (previous?.keyboardReserve ?? 0);
input.keyboardShift > 0 || policy === "release"
? input.keyboardShift
: (previous?.keyboardReserve ?? 0);
return {
keyboardReserve,
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
@@ -8,13 +8,18 @@ import Animated, {
type SharedValue,
} from "react-native-reanimated";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { updateComposerCapacity, type ComposerCapacity } from "./capacity";
import {
updateComposerCapacity,
type ComposerCapacity,
type KeyboardReservePolicy,
} from "./capacity";
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
interface ComposerViewportProps extends ViewProps {
bottomInset?: number;
centered?: boolean;
keyboardReserve?: KeyboardReservePolicy;
}
/** Measure the stationary space below the header, outside keyboard translation. */
@@ -23,6 +28,7 @@ export function ComposerViewport({
onLayout,
bottomInset = 0,
centered = false,
keyboardReserve = "release",
...props
}: ComposerViewportProps) {
const measuredHeight = useSharedValue(0);
@@ -37,8 +43,9 @@ export function ComposerViewport({
}),
(geometry) => {
if (geometry.height <= 0) return;
sizing.value = updateComposerCapacity(sizing.value, geometry);
sizing.value = updateComposerCapacity(sizing.value, geometry, keyboardReserve);
},
[keyboardReserve],
);
const capacity = useDerivedValue(() => sizing.value?.capacity);
const measureViewport = useCallback(
@@ -3,11 +3,13 @@ import { View, type ViewProps } from "react-native";
interface ComposerViewportProps extends ViewProps {
bottomInset?: number;
centered?: boolean;
keyboardReserve?: "retain" | "release";
}
export function ComposerViewport({
bottomInset: _bottomInset,
centered: _centered,
keyboardReserve: _keyboardReserve,
...props
}: ComposerViewportProps) {
return <View {...props} />;
+1 -1
View File
@@ -1280,7 +1280,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
setText={agentInputDraft.replaceText}
onRewindComplete={handleRewindComplete}
>
<ComposerViewport style={styles.root}>
<ComposerViewport style={styles.root} keyboardReserve="retain">
<DockedChatSurface disabled={isArchivingCurrentAgent}>
{contentContainer}