From d1a53255fcd559bccd95f3f9e0b6ba85dc11d420 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 12:25:29 +0200 Subject: [PATCH] fix(app): keep growing composers below the header (#4824) * fix(app): keep growing composers below the header Native keyboard translation left the dock at full height, so intrinsic composer growth could extend behind the header. Reserve the keyboard destination at motion boundaries and bound the composer to its stationary viewport while preserving native input scrolling and animation. * docs: reuse existing test suites and automation --- CLAUDE.md | 1 + docs/floating-panels.md | 11 ++ docs/mobile-testing.md | 7 + .../e2e/mobile/composer-keyboard/android.sh | 25 +++ .../assert-composer-keyboard.mjs | 37 +++- .../app/src/composer/draft/workspace-tab.tsx | 129 +++++++------- packages/app/src/composer/index.tsx | 3 +- .../src/composer/viewport/capacity.test.ts | 35 ++++ .../app/src/composer/viewport/capacity.ts | 13 ++ .../src/composer/viewport/index.native.tsx | 66 ++++++++ packages/app/src/composer/viewport/index.tsx | 18 ++ .../app/src/hooks/keyboard-shift-context.ts | 2 + .../app/src/hooks/keyboard-shift-policy.ts | 10 ++ .../hooks/use-keyboard-shift-style.test.ts | 23 +++ .../app/src/hooks/use-keyboard-shift-style.ts | 33 +++- packages/app/src/panels/agent-panel.tsx | 9 +- .../app/src/screens/new-workspace-screen.tsx | 159 ++++++++++-------- 17 files changed, 440 insertions(+), 141 deletions(-) create mode 100644 packages/app/src/composer/viewport/capacity.test.ts create mode 100644 packages/app/src/composer/viewport/capacity.ts create mode 100644 packages/app/src/composer/viewport/index.native.tsx create mode 100644 packages/app/src/composer/viewport/index.tsx diff --git a/CLAUDE.md b/CLAUDE.md index b6ecb7ef7e..b1f8a294ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,7 @@ and updating `next`, integrating it after a release, and releasing a hotfix from - If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run --bail=1 > /tmp/test-output.txt 2>&1` then read the file. - Never re-run a test suite that another agent already ran and reported green — trust the result. - For full suite verification, push to CI and check GitHub Actions instead. +- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones. - **Always run typecheck and lint after every change.** - **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current: - `npm run build:client` — rebuild protocol and client declarations. diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 69e8f2b105..616288fbf3 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -154,6 +154,17 @@ layout. Preserve the translated dock's history scroll range with a far-end content inset on the inverted stream list. Update that inset only when keyboard 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 and release space at move end 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. +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. + Move the stream and composer together through `KeyboardDock`. Do not translate them independently. diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index b341a2d80e..b51033d484 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -47,6 +47,13 @@ focused input identity and IME hide/show events. It saves screenshots and logs u `.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo` to test an installed production build. It never submits a chat message. +`npm run test:e2e:composer-keyboard:android` covers composer growth in chat and +New workspace, plus keyboard and control interactions. Configure its daemon, +Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in +`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard; +the headless input helper cannot verify the +[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors). + When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts. ## Maestro compatibility diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index c6739b02b1..86554ff998 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -275,6 +275,11 @@ node "${ASSERT}" xml-above-y \ "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ "Add attachment" \ "$(read_ime_top)" +display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')" +node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" adb shell ime set "${HELPER_IME}" >/dev/null ad fill 'editable=true' "${MESSAGE}" --settle open_gboard "${input_x}" "${input_y}" @@ -337,4 +342,24 @@ node "${ASSERT}" same-region \ 1400 \ 430 +ad press 'id="menu-button"' --settle +ad press 'id="sidebar-global-new-workspace"' --settle +ad wait 'id="workspace-create-submit"' 10000 +adb shell ime set "${HELPER_IME}" >/dev/null +ad fill 'editable=true' "${LONG_MESSAGE}" --settle +snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json" +read -r new_input_x new_input_y new_input_height < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable" +) +# The editor shrinks when the keyboard opens. Its bottom stays anchored, while +# its old center can move into the setup fields after the height constraint. +new_input_y="$((new_input_y + new_input_height / 2 - 8))" +open_gboard "${new_input_x}" "${new_input_y}" +capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" +capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png" +node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" + 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 3aaff5b2e0..255d442fde 100644 --- a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs @@ -4,11 +4,44 @@ import sharp from "sharp"; const [, , command, ...args] = process.argv; -if (command === "rect") { +if (command === "xml-composer-contained") { + const [snapshotPath, imeTopArgument, densityArgument] = args; + const snapshot = await fs.readFile(snapshotPath, "utf8"); + const boundsFor = (id) => { + const node = snapshot.match(new RegExp(`]*resource-id="${id}"[^>]*>`)); + if (!node) throw new Error(`Missing node: ${id}`); + const bounds = node[0].match(/bounds="\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]"/); + if (!bounds) throw new Error(`Missing bounds: ${id}`); + return { top: Number(bounds[2]), bottom: Number(bounds[4]) }; + }; + const viewport = boundsFor("composer-viewport"); + const content = boundsFor("composer-viewport-content"); + const composer = boundsFor("message-input-root"); + const controls = boundsFor("message-input-attach-button"); + const clearance = (5 * Number(densityArgument)) / 160; + const minimumTop = viewport.top + clearance; + // Android rounds layout units to physical pixels; allow one pixel of rounding. + if (content.top < minimumTop - 1 || composer.top < minimumTop - 1) { + throw new Error( + `Composer overlaps header: content=${content.top}, editor=${composer.top}, minimum=${minimumTop}`, + ); + } + if (composer.bottom > Number(imeTopArgument) || controls.bottom > Number(imeTopArgument)) { + throw new Error( + `Composer extends behind keyboard: editor=${composer.bottom}, controls=${controls.bottom}, keyboard=${imeTopArgument}`, + ); + } + process.stdout.write( + `Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`, + ); +} else if (command === "rect") { const [snapshotPath, identifier] = args; const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); const node = snapshot.data.nodes.find( - (candidate) => candidate.identifier === identifier || candidate.label === identifier, + (candidate) => + candidate.identifier === identifier || + candidate.label === identifier || + (identifier === "editable" && candidate.type.endsWith("EditText")), ); if (!node) throw new Error(`Missing node: ${identifier}`); const { x, y, width, height } = node.rect; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 4dca9df166..742380789f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; +import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; @@ -621,75 +622,83 @@ export function WorkspaceDraftAgentTab({ [composerState.agentControls, handleDropdownCloseFocus, isSubmitting], ); return ( - - - {isSubmitting && draftAgent ? ( - - + + + {isSubmitting && draftAgent ? ( + + + + ) : ( + + + {formErrorMessage ? ( + + {formErrorMessage} + + ) : null} + + + )} + + + + + {importPillPress ? ( + + + + + + ) : null} + - - ) : ( - - - {formErrorMessage ? ( - - {formErrorMessage} - - ) : null} - - - )} - - - - {importPillPress ? ( - - - - - - ) : null} - - - + + + + ); } const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", + flexShrink: 1, }, }); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 84de49a3b9..fa2f994fc9 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -2407,8 +2407,7 @@ 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. + // Propagate the viewport's height constraint down to the scrolling input. flexShrink: 1, position: "relative", }, diff --git a/packages/app/src/composer/viewport/capacity.test.ts b/packages/app/src/composer/viewport/capacity.test.ts new file mode 100644 index 0000000000..04b82e04ea --- /dev/null +++ b/packages/app/src/composer/viewport/capacity.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { resolveComposerCapacity } from "./capacity"; + +describe("composer viewport", () => { + it("leaves five points below the header for a bottom-anchored composer", () => { + const height = resolveComposerCapacity({ + height: 582, + bottomInset: 24, + keyboardShift: 308, + centered: false, + }); + expect(height).toBe(245); + expect(582 - 24 - 308 - height).toBe(5); + }); + + it("keeps a centered tablet form below the header after translation", () => { + const height = resolveComposerCapacity({ + height: 1000, + bottomInset: 80, + keyboardShift: 300, + centered: true, + }); + expect(height).toBe(310); + expect((1000 - 80 - height) / 2 - 300).toBe(5); + }); + + it("releases space after closing and responds to rotation instead of using window height", () => { + expect( + resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }), + ).toBe(553); + expect( + resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }), + ).toBe(95); + }); +}); diff --git a/packages/app/src/composer/viewport/capacity.ts b/packages/app/src/composer/viewport/capacity.ts new file mode 100644 index 0000000000..7e80b22964 --- /dev/null +++ b/packages/app/src/composer/viewport/capacity.ts @@ -0,0 +1,13 @@ +export function resolveComposerCapacity(input: { + height: number; + bottomInset: number; + keyboardShift: number; + centered: boolean; +}): number { + "worklet"; + // A centered form grows upward by half its height. Reserve both halves so + // translating it still leaves five layout points below the header. + const clearance = input.keyboardShift + 5; + const reservedSpace = input.centered ? clearance * 2 : clearance; + return Math.max(0, input.height - input.bottomInset - reservedSpace); +} diff --git a/packages/app/src/composer/viewport/index.native.tsx b/packages/app/src/composer/viewport/index.native.tsx new file mode 100644 index 0000000000..0ee128a0f8 --- /dev/null +++ b/packages/app/src/composer/viewport/index.native.tsx @@ -0,0 +1,66 @@ +import { createContext, useCallback, useContext } from "react"; +import { View, type LayoutChangeEvent, type ViewProps } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + useDerivedValue, + type SharedValue, +} from "react-native-reanimated"; +import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { resolveComposerCapacity } from "./capacity"; + +const ViewportCapacity = createContext | null>(null); + +interface ComposerViewportProps extends ViewProps { + bottomInset?: number; + centered?: boolean; +} + +/** Measure the stationary space below the header, outside keyboard translation. */ +export function ComposerViewport({ + children, + onLayout, + bottomInset = 0, + centered = false, + ...props +}: ComposerViewportProps) { + const measuredHeight = useSharedValue(0); + const { layoutShift } = useKeyboardShift(); + const capacity = useDerivedValue(() => { + if (measuredHeight.value === 0) return undefined; + return resolveComposerCapacity({ + height: measuredHeight.value, + bottomInset, + keyboardShift: layoutShift.value, + centered, + }); + }); + const measureViewport = useCallback( + (event: LayoutChangeEvent) => { + measuredHeight.value = event.nativeEvent.layout.height; + onLayout?.(event); + }, + [measuredHeight, onLayout], + ); + + return ( + + {children} + + ); +} + +/** Bound the complete composer (including its controls), not just the text input. */ +export function ComposerViewportContent({ style, ...props }: ViewProps) { + const capacity = useContext(ViewportCapacity); + if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport"); + const constraint = useAnimatedStyle(() => ({ + // Only viewport changes and keyboard start/end events trigger layout. Motion + // stays in KeyboardTranslateView and never changes this constraint per frame. + maxHeight: capacity.value, + })); + + return ( + + ); +} diff --git a/packages/app/src/composer/viewport/index.tsx b/packages/app/src/composer/viewport/index.tsx new file mode 100644 index 0000000000..bc2846a8bd --- /dev/null +++ b/packages/app/src/composer/viewport/index.tsx @@ -0,0 +1,18 @@ +import { View, type ViewProps } from "react-native"; + +interface ComposerViewportProps extends ViewProps { + bottomInset?: number; + centered?: boolean; +} + +export function ComposerViewport({ + bottomInset: _bottomInset, + centered: _centered, + ...props +}: ComposerViewportProps) { + return ; +} + +export function ComposerViewportContent(props: ViewProps) { + return ; +} diff --git a/packages/app/src/hooks/keyboard-shift-context.ts b/packages/app/src/hooks/keyboard-shift-context.ts index 9d41f13224..dcb0b4cf64 100644 --- a/packages/app/src/hooks/keyboard-shift-context.ts +++ b/packages/app/src/hooks/keyboard-shift-context.ts @@ -3,6 +3,8 @@ import type { SharedValue } from "react-native-reanimated"; export interface KeyboardShiftContextValue { shift: SharedValue; + /** Space reserved at keyboard boundaries, never updated by animation frames. */ + layoutShift: SharedValue; isMoving: SharedValue; bottomInset: SharedValue; } diff --git a/packages/app/src/hooks/keyboard-shift-policy.ts b/packages/app/src/hooks/keyboard-shift-policy.ts index 19c021a26c..97435027fa 100644 --- a/packages/app/src/hooks/keyboard-shift-policy.ts +++ b/packages/app/src/hooks/keyboard-shift-policy.ts @@ -1,5 +1,15 @@ export const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; +/** Reserve both endpoints during motion so content cannot grow into a closing IME. */ +export function reserveKeyboardLayoutShift(input: { + current: number; + target: number; + phase: "start" | "end"; +}): number { + "worklet"; + return input.phase === "start" ? Math.max(input.current, input.target) : input.target; +} + export function resolveStreamKeyboardInset(input: { platform: "android" | "ios"; settledShift: number; diff --git a/packages/app/src/hooks/use-keyboard-shift-style.test.ts b/packages/app/src/hooks/use-keyboard-shift-style.test.ts index bada7dd74c..c85f26dde4 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.test.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.test.ts @@ -3,9 +3,32 @@ import { resolveStreamKeyboardInset, shouldReconcileHiddenKeyboardEnd, resolveKeyboardShift, + reserveKeyboardLayoutShift, shouldUseCompactExplorerKeyboardPadding, } from "./keyboard-shift-policy"; +describe("keyboard layout reservation", () => { + it("reserves the opening destination before motion and releases it only after closing", () => { + const opened = reserveKeyboardLayoutShift({ current: 0, target: 308, phase: "start" }); + expect(opened).toBe(308); + const closing = reserveKeyboardLayoutShift({ current: opened, target: 0, phase: "start" }); + expect(closing).toBe(308); + expect(reserveKeyboardLayoutShift({ current: closing, target: 0, phase: "end" })).toBe(0); + }); + + it("keeps enough space when a resize or reversal interrupts keyboard motion", () => { + const resizing = reserveKeyboardLayoutShift({ current: 308, target: 250, phase: "start" }); + expect(resizing).toBe(308); + const reversing = reserveKeyboardLayoutShift({ + current: resizing, + target: 340, + phase: "start", + }); + expect(reversing).toBe(340); + expect(reserveKeyboardLayoutShift({ current: reversing, target: 340, phase: "end" })).toBe(340); + }); +}); + describe("resolveStreamKeyboardInset", () => { it("uses the native scroll inset on iOS without changing content size", () => { expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: 311 })).toEqual({ diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index 126ab9e9b3..2bd57d105c 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -17,6 +17,7 @@ import { scheduleOnRN } from "react-native-worklets"; import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, + reserveKeyboardLayoutShift, shouldReconcileHiddenKeyboardEnd, } from "@/hooks/keyboard-shift-policy"; import { @@ -33,6 +34,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const bottomInset = useSharedValue(insets.bottom); const isIos = Platform.OS === "ios"; const isMoving = useSharedValue(false); + const layoutShift = useSharedValue(0); const [settledShift, setSettledShift] = useState(0); const publishSettledShift = useCallback((nextShift: number) => { setSettledShift((currentShift) => (currentShift === nextShift ? currentShift : nextShift)); @@ -55,12 +57,36 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { useGenericKeyboardHandler( { - onStart: () => { + onStart: (event) => { "worklet"; + const target = resolveKeyboardShift({ + rawKeyboardHeight: event.height, + keyboardProgress: event.height > 0 ? 1 : 0, + bottomInset: bottomInset.value, + isIos, + iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, + }); + layoutShift.value = reserveKeyboardLayoutShift({ + current: layoutShift.value, + target, + phase: "start", + }); isMoving.value = true; }, onEnd: (event) => { "worklet"; + const target = resolveKeyboardShift({ + rawKeyboardHeight: event.height, + keyboardProgress: event.progress, + bottomInset: bottomInset.value, + isIos, + iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, + }); + layoutShift.value = reserveKeyboardLayoutShift({ + current: layoutShift.value, + target, + phase: "end", + }); if (isIos && shouldReconcileHiddenKeyboardEnd(event)) { keyboardHeight.value = 0; keyboardProgress.value = 0; @@ -68,7 +94,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { isMoving.value = false; }, }, - [isIos, isMoving, keyboardHeight, keyboardProgress], + [bottomInset, isIos, isMoving, keyboardHeight, keyboardProgress, layoutShift], ); useAnimatedReaction( @@ -84,10 +110,11 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ shift, + layoutShift, isMoving, bottomInset, }), - [bottomInset, isMoving, shift], + [bottomInset, isMoving, layoutShift, shift], ); return createElement( diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 84f86bf745..5f26342542 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -24,6 +24,7 @@ import { useStoreWithEqualityFn } from "zustand/traditional"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { KeyboardDock } from "@/components/keyboard-dock"; +import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { Composer } from "@/composer"; @@ -1359,7 +1360,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ setText={agentInputDraft.replaceText} onRewindComplete={handleRewindComplete} > - + {contentContainer} @@ -1386,7 +1387,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ) : null} - {composerSection} + + {composerSection} + {showHistorySyncOverlay ? ( @@ -1404,7 +1407,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ {t("agentPanel.states.archivingSubtitle")} ) : null} - + ); }); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 7f7b6b13a8..a9baaf007d 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -12,6 +12,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; +import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; +import { ScrollView } from "@/components/ui/scroll-view"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { resolveComposerAttachmentSubmitFormat, @@ -745,7 +747,7 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { if (input.isCompact) { return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }]; } - return [styles.content, styles.contentCentered]; + return [styles.content, styles.contentCentered, { paddingBottom: input.insetBottom }]; } function normalizeBranchDetails( @@ -2282,9 +2284,13 @@ export function NewWorkspaceScreen({ ], ); + const contentBottomInset = useMemo( + () => (isCompact ? insets.bottom : HEADER_INNER_HEIGHT + theme.spacing[6]), + [isCompact, insets.bottom, theme.spacing], + ); const contentStyle = useMemo( - () => getContentStyle({ isCompact, insetBottom: insets.bottom }), - [isCompact, insets.bottom], + () => getContentStyle({ isCompact, insetBottom: contentBottomInset }), + [isCompact, contentBottomInset], ); const agentControlsWithDisabled = useMemo( @@ -2370,85 +2376,97 @@ export function NewWorkspaceScreen({ return ( - + - - {t("newWorkspace.title")} - - {formStack} - {isTerminalLaunch ? ( - - ) : ( - - )} - {errorMessage ? {errorMessage} : null} + + + + {t("newWorkspace.title")} + + {formStack} + + {isTerminalLaunch ? ( + + ) : ( + + )} + {errorMessage ? {errorMessage} : null} + - + ); } const animatedStaticStyles = RNStyleSheet.create({ centered: { + flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH, }, + form: { + flexShrink: 1, + }, + setup: { + flexGrow: 0, + flexShrink: 1, + }, }); const styles = StyleSheet.create((theme) => ({ @@ -2464,7 +2482,6 @@ const styles = StyleSheet.create((theme) => ({ }, contentCentered: { justifyContent: "center", - paddingBottom: HEADER_INNER_HEIGHT + theme.spacing[6], }, contentCompact: { justifyContent: "flex-end",