mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
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:
+17
-9
@@ -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 ? (
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user