mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
fix(app): keep composer layout stable across chat and workspace screens (#4973)
* fix(app): dock the New workspace composer like a chat on phones Only the composer is bounded by the viewport; the setup fields sit above it in a bottom-anchored stack that moves up as the draft grows. The keyboard reservation is retained on every screen, so the composer keeps one height across keyboard transitions. Removes the per-screen reserve policy. * fix(app): share composer docking across mobile hosts Extract chat's retained capacity and platform keyboard motion so chat, workspace drafts, and New workspace share one sizing and dismissal boundary. Keep the native drivers and settled portal reconciliation unchanged. * fix(app): preserve overlays in centered composer docks Honor the optional overlay child in both layouts so changing dock placement does not silently discard part of its content. * test(app): resolve keyboard controller through its web entry The shared keyboard entry point makes stream tests load the provider. Keep its real ESM dependencies inside the Vitest resolver so CommonJS does not bypass the React Native web alias and parse native Flow sources. * fix(app): keep native chat scrolling responsive Place keyboard dismissal behind scrollable content so an idle Android Fabric JS responder cannot intercept native drags. Keep decorative form padding transparent to iOS hit testing and exercise idle native scrolling after submission from every composer host.
This commit is contained in:
@@ -1,31 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
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>;
|
||||
}
|
||||
|
||||
export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
|
||||
export const SettledKeyboardShiftContext = createContext<number | null>(null);
|
||||
|
||||
/** Read the app-wide keyboard inset without loading its native provider implementation. */
|
||||
export function useKeyboardShift(): KeyboardShiftContextValue {
|
||||
const context = useContext(KeyboardShiftContext);
|
||||
if (!context) {
|
||||
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/** Read the keyboard inset published after native keyboard motion has settled. */
|
||||
export function useSettledKeyboardShift(): number {
|
||||
const context = useContext(SettledKeyboardShiftContext);
|
||||
if (context === null) {
|
||||
throw new Error("useSettledKeyboardShift must be used inside KeyboardShiftProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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;
|
||||
}): {
|
||||
contentContainerPaddingBottom: number;
|
||||
contentInset: { bottom: number } | undefined;
|
||||
} {
|
||||
const settledShift = Math.max(0, input.settledShift);
|
||||
if (input.platform === "ios") {
|
||||
return {
|
||||
contentContainerPaddingBottom: 0,
|
||||
contentInset: { bottom: settledShift },
|
||||
};
|
||||
}
|
||||
return {
|
||||
contentContainerPaddingBottom: settledShift,
|
||||
contentInset: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseCompactExplorerKeyboardPadding(input: {
|
||||
isGit: boolean;
|
||||
explorerTab: "changes" | "files" | "pr";
|
||||
}): boolean {
|
||||
return !input.isGit || input.explorerTab !== "changes";
|
||||
}
|
||||
|
||||
export function resolveKeyboardShift(input: {
|
||||
rawKeyboardHeight: number;
|
||||
keyboardProgress: number;
|
||||
bottomInset: number;
|
||||
isIos: boolean;
|
||||
iosMinHeight: number;
|
||||
}): number {
|
||||
"worklet";
|
||||
|
||||
if (!(input.keyboardProgress > 0) || !(input.rawKeyboardHeight > 0)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// iOS can report a small accessory/prediction bar height during touch focus.
|
||||
// Treat that as non-keyboard so layouts don't "bounce" while interacting.
|
||||
if (input.isIos && input.rawKeyboardHeight < input.iosMinHeight) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, input.rawKeyboardHeight - input.bottomInset);
|
||||
}
|
||||
|
||||
export function shouldReconcileHiddenKeyboardEnd(input: {
|
||||
height: number;
|
||||
progress: number;
|
||||
}): boolean {
|
||||
"worklet";
|
||||
return !(input.height > 0) || !(input.progress > 0);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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({
|
||||
contentContainerPaddingBottom: 0,
|
||||
contentInset: { bottom: 311 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Android's exact content-container padding behavior", () => {
|
||||
expect(resolveStreamKeyboardInset({ platform: "android", settledShift: 311 })).toEqual({
|
||||
contentContainerPaddingBottom: 311,
|
||||
contentInset: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose a negative inset", () => {
|
||||
expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: -1 })).toEqual({
|
||||
contentContainerPaddingBottom: 0,
|
||||
contentInset: { bottom: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeyboardShift", () => {
|
||||
it("keeps the existing open-keyboard offset behavior", () => {
|
||||
expect(
|
||||
resolveKeyboardShift({
|
||||
rawKeyboardHeight: 320,
|
||||
keyboardProgress: 1,
|
||||
bottomInset: 24,
|
||||
isIos: false,
|
||||
iosMinHeight: 120,
|
||||
}),
|
||||
).toBe(296);
|
||||
});
|
||||
|
||||
it("treats progress zero as closed even when Android reports a stale height", () => {
|
||||
expect(
|
||||
resolveKeyboardShift({
|
||||
rawKeyboardHeight: 320,
|
||||
keyboardProgress: 0,
|
||||
bottomInset: 24,
|
||||
isIos: false,
|
||||
iosMinHeight: 120,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("still ignores small iOS accessory bar reports", () => {
|
||||
expect(
|
||||
resolveKeyboardShift({
|
||||
rawKeyboardHeight: 80,
|
||||
keyboardProgress: 1,
|
||||
bottomInset: 0,
|
||||
isIos: true,
|
||||
iosMinHeight: 120,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldReconcileHiddenKeyboardEnd", () => {
|
||||
it("closes stale iOS keyboard state without letting a late visible end resurrect it", () => {
|
||||
expect(
|
||||
shouldReconcileHiddenKeyboardEnd({
|
||||
height: 0,
|
||||
progress: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldReconcileHiddenKeyboardEnd({
|
||||
height: 320,
|
||||
progress: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUseCompactExplorerKeyboardPadding", () => {
|
||||
it("keeps the changes viewport stable while preserving padding for other tabs", () => {
|
||||
expect(shouldUseCompactExplorerKeyboardPadding({ isGit: true, explorerTab: "changes" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldUseCompactExplorerKeyboardPadding({ isGit: true, explorerTab: "files" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldUseCompactExplorerKeyboardPadding({ isGit: false, explorerTab: "changes" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import { createElement, useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
useGenericKeyboardHandler,
|
||||
useReanimatedKeyboardAnimation,
|
||||
} from "react-native-keyboard-controller";
|
||||
import {
|
||||
useAnimatedStyle,
|
||||
useAnimatedReaction,
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import {
|
||||
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
|
||||
resolveKeyboardShift,
|
||||
reserveKeyboardLayoutShift,
|
||||
shouldReconcileHiddenKeyboardEnd,
|
||||
} from "@/hooks/keyboard-shift-policy";
|
||||
import {
|
||||
KeyboardShiftContext,
|
||||
SettledKeyboardShiftContext,
|
||||
useKeyboardShift,
|
||||
} from "@/hooks/keyboard-shift-context";
|
||||
|
||||
type KeyboardShiftMode = "translate" | "padding";
|
||||
|
||||
export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation();
|
||||
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));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
bottomInset.value = insets.bottom;
|
||||
}, [bottomInset, insets.bottom]);
|
||||
|
||||
const shift = useDerivedValue(() => {
|
||||
"worklet";
|
||||
return resolveKeyboardShift({
|
||||
rawKeyboardHeight: Math.abs(keyboardHeight.value),
|
||||
keyboardProgress: keyboardProgress.value,
|
||||
bottomInset: bottomInset.value,
|
||||
isIos,
|
||||
iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
useGenericKeyboardHandler(
|
||||
{
|
||||
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;
|
||||
}
|
||||
isMoving.value = false;
|
||||
},
|
||||
},
|
||||
[bottomInset, isIos, isMoving, keyboardHeight, keyboardProgress, layoutShift],
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => ({ moving: isMoving.value, shift: shift.value }),
|
||||
(current, previous) => {
|
||||
if (!current.moving && (previous === null || previous.moving)) {
|
||||
scheduleOnRN(publishSettledShift, current.shift);
|
||||
}
|
||||
},
|
||||
[isMoving, publishSettledShift, shift],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
shift,
|
||||
layoutShift,
|
||||
isMoving,
|
||||
bottomInset,
|
||||
}),
|
||||
[bottomInset, isMoving, layoutShift, shift],
|
||||
);
|
||||
|
||||
return createElement(
|
||||
KeyboardShiftContext.Provider,
|
||||
{ value },
|
||||
createElement(SettledKeyboardShiftContext.Provider, { value: settledShift }, children),
|
||||
);
|
||||
}
|
||||
|
||||
export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): {
|
||||
shift: SharedValue<number>;
|
||||
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
|
||||
} {
|
||||
const { shift, bottomInset } = useKeyboardShift();
|
||||
const mode = input.mode;
|
||||
const enabled = input.enabled ?? true;
|
||||
|
||||
const style = useAnimatedStyle<ViewStyle>(() => {
|
||||
"worklet";
|
||||
if (mode === "padding") {
|
||||
if (!enabled) {
|
||||
return { paddingBottom: 0 };
|
||||
}
|
||||
// Include safe-area bottom inset so content clears the home indicator even without a keyboard.
|
||||
return { paddingBottom: bottomInset.value + shift.value };
|
||||
}
|
||||
|
||||
return { transform: [{ translateY: enabled ? -shift.value : 0 }] };
|
||||
}, [enabled, mode]);
|
||||
|
||||
return { shift, style };
|
||||
}
|
||||
Reference in New Issue
Block a user