mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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
This commit is contained in:
@@ -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 <file> --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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(`<node[^>]*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;
|
||||
|
||||
@@ -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 (
|
||||
<FileDropZone style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
{isSubmitting && draftAgent ? (
|
||||
<View style={styles.streamContainer}>
|
||||
<AgentStreamView
|
||||
<ComposerViewport style={styles.container} bottomInset={insets.bottom}>
|
||||
<FileDropZone style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
{isSubmitting && draftAgent ? (
|
||||
<View style={styles.streamContainer}>
|
||||
<AgentStreamView
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
context={draftAgent}
|
||||
streamItems={submittedStreamItems}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.configScrollContent}
|
||||
>
|
||||
<View style={styles.configSection}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<KeyboardTranslateView style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
|
||||
<ComposerViewportContent style={animatedStaticStyles.inputAreaWrapper}>
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
context={draftAgent}
|
||||
streamItems={submittedStreamItems}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
workspaceId={workspaceId}
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
value={draftInput.text}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacement={draftInput.textReplacement}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
|
||||
<View style={styles.configSection}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<KeyboardTranslateView style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
value={draftInput.text}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacement={draftInput.textReplacement}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</KeyboardTranslateView>
|
||||
</FileDropZone>
|
||||
</ComposerViewportContent>
|
||||
</KeyboardTranslateView>
|
||||
</FileDropZone>
|
||||
</ComposerViewport>
|
||||
);
|
||||
}
|
||||
|
||||
const animatedStaticStyles = RNStyleSheet.create({
|
||||
inputAreaWrapper: {
|
||||
width: "100%",
|
||||
flexShrink: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<SharedValue<number | undefined> | 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 (
|
||||
<View testID="composer-viewport" {...props} onLayout={measureViewport} collapsable={false}>
|
||||
<ViewportCapacity.Provider value={capacity}>{children}</ViewportCapacity.Provider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Animated.View testID="composer-viewport-content" {...props} style={[style, constraint]} />
|
||||
);
|
||||
}
|
||||
@@ -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 <View {...props} />;
|
||||
}
|
||||
|
||||
export function ComposerViewportContent(props: ViewProps) {
|
||||
return <View {...props} />;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import type { SharedValue } from "react-native-reanimated";
|
||||
|
||||
export interface KeyboardShiftContextValue {
|
||||
shift: SharedValue<number>;
|
||||
/** Space reserved at keyboard boundaries, never updated by animation frames. */
|
||||
layoutShift: SharedValue<number>;
|
||||
isMoving: SharedValue<boolean>;
|
||||
bottomInset: SharedValue<number>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<View style={styles.root} collapsable={false}>
|
||||
<ComposerViewport style={styles.root}>
|
||||
<DockedChatSurface disabled={isArchivingCurrentAgent}>
|
||||
{contentContainer}
|
||||
|
||||
@@ -1386,7 +1387,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{composerSection}
|
||||
<ComposerViewportContent style={animatedStaticStyles.inputAreaWrapper}>
|
||||
{composerSection}
|
||||
</ComposerViewportContent>
|
||||
|
||||
{showHistorySyncOverlay ? (
|
||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||
@@ -1404,7 +1407,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
<Text style={styles.archivingSubtitle}>{t("agentPanel.states.archivingSubtitle")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ComposerViewport>
|
||||
</RewindComposerRestoreProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<FileDropZone style={styles.container}>
|
||||
<ScreenHeader left={screenHeaderLeft} borderless />
|
||||
<View style={contentStyle}>
|
||||
<ComposerViewport style={contentStyle} bottomInset={contentBottomInset} centered={!isCompact}>
|
||||
<TitlebarDragRegion />
|
||||
<KeyboardTranslateView style={animatedStaticStyles.centered}>
|
||||
<View style={styles.composerTitleContainer}>
|
||||
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
|
||||
</View>
|
||||
{formStack}
|
||||
{isTerminalLaunch ? (
|
||||
<Composer
|
||||
key="terminal"
|
||||
externalKeyboardShift
|
||||
inputMode="terminal"
|
||||
readOnly={!terminalTakesPrompt}
|
||||
placeholder={terminalPlaceholder}
|
||||
submitLabel={terminalSubmitLabel}
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitTerminalLaunch}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
|
||||
submitButtonTestID="new-workspace-launch-submit"
|
||||
isSubmitLoading={isPending}
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={terminalComposerValue}
|
||||
onChangeText={setTerminalPromptText}
|
||||
textReplacement={terminalTextReplacement}
|
||||
attachments={NO_TERMINAL_ATTACHMENTS}
|
||||
onChangeAttachments={noopChangeAttachments}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={noopClearDraft}
|
||||
autoFocus={terminalTakesPrompt}
|
||||
autoFocusKey={launchFocusKey}
|
||||
/>
|
||||
) : (
|
||||
<Composer
|
||||
key="chat"
|
||||
externalKeyboardShift
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||
submitButtonTestID="workspace-create-submit"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={isPending}
|
||||
waitForForgeAutoAttachOnSubmit
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacement={chatDraft.textReplacement}
|
||||
attachments={chatDraft.attachments}
|
||||
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
onForgeChangeRequestDetected={handleForgeChangeRequestDetected}
|
||||
onForgeChangeRequestAutoAttach={handleForgeChangeRequestAutoAttach}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
autoFocusKey={launchFocusKey}
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
/>
|
||||
)}
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
<ComposerViewportContent style={animatedStaticStyles.form}>
|
||||
<ScrollView style={animatedStaticStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.composerTitleContainer}>
|
||||
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
|
||||
</View>
|
||||
{formStack}
|
||||
</ScrollView>
|
||||
{isTerminalLaunch ? (
|
||||
<Composer
|
||||
key="terminal"
|
||||
externalKeyboardShift
|
||||
inputMode="terminal"
|
||||
readOnly={!terminalTakesPrompt}
|
||||
placeholder={terminalPlaceholder}
|
||||
submitLabel={terminalSubmitLabel}
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitTerminalLaunch}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
|
||||
submitButtonTestID="new-workspace-launch-submit"
|
||||
isSubmitLoading={isPending}
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={terminalComposerValue}
|
||||
onChangeText={setTerminalPromptText}
|
||||
textReplacement={terminalTextReplacement}
|
||||
attachments={NO_TERMINAL_ATTACHMENTS}
|
||||
onChangeAttachments={noopChangeAttachments}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={noopClearDraft}
|
||||
autoFocus={terminalTakesPrompt}
|
||||
autoFocusKey={launchFocusKey}
|
||||
/>
|
||||
) : (
|
||||
<Composer
|
||||
key="chat"
|
||||
externalKeyboardShift
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||
submitButtonTestID="workspace-create-submit"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={isPending}
|
||||
waitForForgeAutoAttachOnSubmit
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacement={chatDraft.textReplacement}
|
||||
attachments={chatDraft.attachments}
|
||||
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
onForgeChangeRequestDetected={handleForgeChangeRequestDetected}
|
||||
onForgeChangeRequestAutoAttach={handleForgeChangeRequestAutoAttach}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
autoFocusKey={launchFocusKey}
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
/>
|
||||
)}
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</ComposerViewportContent>
|
||||
</KeyboardTranslateView>
|
||||
</View>
|
||||
</ComposerViewport>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user