diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index bc4e11b505..c6739b02b1 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -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" ) 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 228c6d466f..3aaff5b2e0 100644 --- a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs @@ -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( + `]*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); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 9f23daed87..ba3225c8f6 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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], diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index a660d8cce5..fc8a260cfb 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1901,9 +1901,11 @@ export const MessageInput = forwardRef( 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", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index f412b10738..5156ee8f28 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1815,6 +1815,7 @@ const animatedStaticStyles = RNStyleSheet.create({ }, inputAreaWrapper: { width: "100%", + flexShrink: 1, }, });