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",