fix(app): keep expanded composer above keyboard (#4051)

Allow the composer constraint chain to shrink inside KeyboardDock so overlong drafts scroll internally without hiding controls behind the IME. Extend the Android keyboard invariant to cover the maximum-height case.
This commit is contained in:
Mohamed Boudra
2026-08-30 18:19:52 +02:00
committed by GitHub
parent b1050d9bed
commit 8533c5c06f
5 changed files with 62 additions and 2 deletions
@@ -2,9 +2,9 @@
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)"
STATE_DIR="${REPO_ROOT}/.dev/agent-device-composer-keyboard"
STATE_DIR="${PASEO_COMPOSER_KEYBOARD_STATE_DIR:-${REPO_ROOT}/.dev/agent-device-composer-keyboard}"
ARTIFACTS_DIR="${REPO_ROOT}/.dev/agent-device-artifacts/composer-keyboard-android"
SESSION="composer-keyboard-android"
SESSION="${PASEO_COMPOSER_KEYBOARD_SESSION:-composer-keyboard-android}"
APP_ID="${PASEO_COMPOSER_KEYBOARD_APP_ID:-sh.paseo.debug}"
DEVICE="${PASEO_COMPOSER_KEYBOARD_DEVICE:-paseo-api35}"
HELPER_IME="com.callstack.agentdevice.imehelper/.TestInputMethodService"
@@ -16,6 +16,7 @@ DAEMON_HOST="${PASEO_COMPOSER_KEYBOARD_DAEMON_HOST:-127.0.0.1:6770}"
DAEMON_HOME="${PASEO_COMPOSER_KEYBOARD_DAEMON_HOME:-${REPO_ROOT}/.dev/composer-e2e-home}"
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(" "))')"
AGENT_TITLE="Keyboard dismiss QA $(date +%s)"
if [[ -z "${SERVER_ID}" ]]; then
@@ -40,6 +41,18 @@ snapshot_json() {
ad snapshot -i --json >"${output_path}"
}
capture_ui_xml() {
local output_path="$1"
local device_path="/sdcard/paseo-composer-keyboard-window.xml"
# Android exposes one UI Automation connection at a time. Release the
# persistent agent-device snapshot helper before asking uiautomator for the
# app and IME windows; agent-device reconnects it on the next interaction.
adb shell am force-stop com.callstack.agentdevice.snapshothelper
sleep 0.2
adb shell uiautomator dump "${device_path}" >/dev/null
adb pull "${device_path}" "${output_path}" >/dev/null
}
clear_input() {
ad fill 'editable=true' "x" --settle
adb shell input keycombination 113 29
@@ -108,6 +121,11 @@ read_keyboard_shift() {
printf '%s\n' "$((navigation_top - ime_top))"
}
read_ime_top() {
adb shell dumpsys window | sed -n \
's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1
}
cleanup() {
adb shell ime set "${HELPER_IME}" >/dev/null 2>&1 || true
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device daemon stop --clean >/dev/null 2>&1 || true
@@ -248,6 +266,20 @@ node "${ASSERT}" same-header \
"${ARTIFACTS_DIR}/keyboard-open.png" \
"$((header_y + 48))"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
node "${ASSERT}" xml-above-y \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"Add attachment" \
"$(read_ime_top)"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
read -r changes_x changes_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
)
@@ -27,6 +27,21 @@ if (command === "rect") {
`${upperIdentifier} extends below the composer top: ${upperBottom} > ${lowerY}`,
);
}
} else if (command === "xml-above-y") {
const [snapshotPath, contentDescription, lowerYArgument] = args;
const snapshot = await fs.readFile(snapshotPath, "utf8");
const escapedDescription = contentDescription.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = snapshot.match(
new RegExp(
`<node[^>]*content-desc="${escapedDescription}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`,
),
);
if (!match) throw new Error(`Missing node: ${contentDescription}`);
const upperBottom = Number(match[4]);
const lowerY = Number(lowerYArgument);
if (upperBottom > lowerY) {
throw new Error(`${contentDescription} extends below the keyboard: ${upperBottom} > ${lowerY}`);
}
} else if (command === "same-header") {
const [baselinePath, keyboardPath, headerBottomArgument] = args;
const headerBottom = Number(headerBottomArgument);
+6
View File
@@ -2368,6 +2368,9 @@ function ComposerContentImpl({
const animatedStaticStyles = RNStyleSheet.create({
container: {
flexDirection: "column",
// KeyboardDock reduces the available column height with bottom padding.
// Keep the composer's constraint chain shrinkable down to its scrolling input.
flexShrink: 1,
position: "relative",
},
});
@@ -2378,6 +2381,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
backgroundColor: theme.colors.border,
},
inputAreaContainer: {
flexShrink: 1,
position: "relative",
minHeight: FOOTER_HEIGHT,
marginHorizontal: "auto",
@@ -2391,11 +2395,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
opacity: 0.6,
},
inputAreaContent: {
flexShrink: 1,
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
gap: theme.spacing[3],
},
messageInputContainer: {
flexShrink: 1,
position: "relative",
width: "100%",
gap: theme.spacing[3],
@@ -1901,9 +1901,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const styles = StyleSheet.create((theme: Theme) => ({
container: {
flexShrink: 1,
position: "relative",
},
inputWrapper: {
flexShrink: 1,
flexDirection: "column",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface1,
@@ -1932,6 +1934,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
borderStyle: "dotted",
},
textInputScrollWrapper: {
flexShrink: 1,
position: "relative",
},
focusHintText: {
@@ -1943,6 +1946,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
opacity: 0.5,
},
textInput: {
// Preserve the controls when an ancestor constrains an overlong draft.
flexShrink: 1,
width: "100%",
color: theme.colors.foreground,
fontSize: theme.fontSize.content,
@@ -1964,6 +1969,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
color: theme.colors.foregroundMuted,
},
buttonRow: {
flexShrink: 0,
flexDirection: "row",
alignItems: "flex-end",
justifyContent: "space-between",
+1
View File
@@ -1815,6 +1815,7 @@ const animatedStaticStyles = RNStyleSheet.create({
},
inputAreaWrapper: {
width: "100%",
flexShrink: 1,
},
});