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 -9
View File
@@ -160,15 +160,23 @@ motion settles; never drive it per frame.
The translated dock's height is not the composer's available height. Bound the
composer against the stationary space below the header through
`composer/viewport`, including its controls and attachments. Reserve the
keyboard destination at move start on the UI thread. Keep the last nonzero
reservation when the keyboard closes so an unchanged draft does not expand.
Viewport changes and subsequent keyboard openings recalculate the capacity.
Waiting for a settled JS update lets a long draft disappear behind the header
during opening. This is a boundary-time layout change, not an animated
layout prop. Keep the input's native intrinsic sizing and internal scrolling.
New workspace also needs its setup fields to scroll when the form exceeds this
space. The Android regression must check both the header and IME boundaries;
checking only the bottom controls allowed this regression through previously.
keyboard destination at move start on the UI thread. Waiting for a settled JS
update lets a long draft disappear behind the header during opening. This is a
boundary-time layout change, not an animated layout prop. Keep the input's
native intrinsic sizing and internal scrolling. Keep the editing input's
`defaultValue` current on every edit: Fabric re-measures the Android input from
the JS text whenever its frame changed, so a stale prop inflates or collapses
the input (see `packages/app/src/components/ui/text-input/text-input.native.tsx`).
What the closed keyboard leaves behind is the viewport's `keyboardReserve`
policy. A docked chat composer retains its last reservation so an unchanged
capped draft does not expand over the stream; subsequent keyboard openings
recalculate it. New workspace releases it: its setup fields scroll inside the
same constrained space while the keyboard is open, so keeping the reservation
after it closes leaves the form clipped and the composer small with the screen
empty above them. The Android regression must check both the header and IME
boundaries; checking only the bottom controls allowed this regression through
previously.
Move the stream and composer together through `KeyboardDock`. Do not translate
them independently.
@@ -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}