diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 77b3f9104c..c66e3f11ab 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -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. diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index 86554ff998..9c05601af2 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -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" diff --git a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs index 255d442fde..353329dd2f 100644 --- a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs @@ -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") { diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx index 16feafabcb..72a15a9b8e 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -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 revision + 1, 0); const assignInputRef = useCallback((input: NativeInput | null) => { inputRef.current = input; @@ -107,6 +120,7 @@ export const EditingTextInput = forwardRef { textRef.current = nextText; onChangeText?.(nextText); + bumpTextRevision(); }, [onChangeText], ); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index b5bc446506..1ed88ea890 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -630,7 +630,7 @@ export function WorkspaceDraftAgentTab({ [composerState.agentControls, handleDropdownCloseFocus, isSubmitting], ); return ( - + {isSubmitting && draftAgent ? ( diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 2ae33eb9f3..0300dd518c 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -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", diff --git a/packages/app/src/composer/viewport/capacity.test.ts b/packages/app/src/composer/viewport/capacity.test.ts index d6fa3f9606..20dc9e9d32 100644 --- a/packages/app/src/composer/viewport/capacity.test.ts +++ b/packages/app/src/composer/viewport/capacity.test.ts @@ -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", () => { diff --git a/packages/app/src/composer/viewport/capacity.ts b/packages/app/src/composer/viewport/capacity.ts index d42537579e..93a518378f 100644 --- a/packages/app/src/composer/viewport/capacity.ts +++ b/packages/app/src/composer/viewport/capacity.ts @@ -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 }), diff --git a/packages/app/src/composer/viewport/index.native.tsx b/packages/app/src/composer/viewport/index.native.tsx index 93d50e532b..8c4808618a 100644 --- a/packages/app/src/composer/viewport/index.native.tsx +++ b/packages/app/src/composer/viewport/index.native.tsx @@ -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 | 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( diff --git a/packages/app/src/composer/viewport/index.tsx b/packages/app/src/composer/viewport/index.tsx index bc2846a8bd..0da782adbc 100644 --- a/packages/app/src/composer/viewport/index.tsx +++ b/packages/app/src/composer/viewport/index.tsx @@ -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 ; diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 284aee0b7a..405fce50fb 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1280,7 +1280,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ setText={agentInputDraft.replaceText} onRewindComplete={handleRewindComplete} > - + {contentContainer}