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:
Mohamed Boudra
2026-09-16 23:43:15 +02:00
committed by GitHub
parent c7db3c5a8c
commit 6830c46e9a
39 changed files with 1054 additions and 544 deletions
@@ -1,11 +1,10 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { Button } from "@/components/ui/button";
import type { Theme } from "@/styles/theme";
import { toErrorMessage } from "@/utils/error-messages";
@@ -17,17 +16,11 @@ interface ArchivedAgentCalloutProps {
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
const containerStyle = useMemo(
() => [styles.container, { paddingBottom: insets.bottom }],
[insets.bottom],
);
const handleUnarchive = useCallback(async () => {
if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true);
@@ -41,7 +34,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
}, [client, isConnected, isUnarchiving, agentId]);
return (
<KeyboardTranslateView style={containerStyle}>
<KeyboardTranslateView style={styles.container}>
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.calloutStack}>
@@ -25,8 +25,8 @@ import {
import { ChangesSurface } from "@/git/diff-pane";
import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { shouldUseCompactExplorerKeyboardPadding } from "@/hooks/keyboard-shift-policy";
import { useKeyboardShiftStyle } from "@/keyboard/shift";
import { shouldUseCompactExplorerKeyboardPadding } from "@/keyboard/shift";
import { WindowChromeSafeArea } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { RetainedPanel, RetainedPanelActivity } from "@/components/retained-panel";
@@ -1,3 +1,4 @@
import { ComposerDockBackground } from "@/composer/dock";
import { useMemo, type ReactNode } from "react";
import type { LayoutChangeEvent } from "react-native";
import { View, type StyleProp, type ViewStyle } from "react-native";
@@ -49,7 +50,7 @@ export function ScreenHeader({
const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]);
return (
<View style={styles.header}>
<ComposerDockBackground style={styles.header} testID="composer-dock-header">
<View style={innerStyle}>
<WindowChromeSafeArea
placement="inline"
@@ -62,7 +63,7 @@ export function ScreenHeader({
<View style={rightCombinedStyle}>{right}</View>
</WindowChromeSafeArea>
</View>
</View>
</ComposerDockBackground>
);
}
-1
View File
@@ -1 +0,0 @@
export * from "./keyboard-dock.native";
@@ -1,15 +0,0 @@
import type { ReactNode } from "react";
import type { ViewProps } from "react-native";
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
interface KeyboardDockProps extends ViewProps {
children: ReactNode;
}
export function KeyboardDock({ children, style, ...props }: KeyboardDockProps) {
return (
<KeyboardTranslateView style={style} {...props}>
{children}
</KeyboardTranslateView>
);
}
@@ -1,10 +0,0 @@
import type { ReactNode } from "react";
import { View, type ViewProps } from "react-native";
interface KeyboardDockProps extends ViewProps {
children: ReactNode;
}
export function KeyboardDock(props: KeyboardDockProps) {
return <View {...props} />;
}
@@ -1,27 +0,0 @@
import type { ReactNode } from "react";
import type { ViewProps } from "react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
interface KeyboardTranslateViewProps extends ViewProps {
children: ReactNode;
enabled?: boolean;
}
export function KeyboardTranslateView({
children,
enabled = true,
style,
...props
}: KeyboardTranslateViewProps) {
const { shift } = useKeyboardShift();
const keyboardStyle = useAnimatedStyle(() => ({
transform: [{ translateY: enabled ? -shift.value : 0 }],
}));
return (
<Animated.View style={[style, keyboardStyle]} {...props}>
{children}
</Animated.View>
);
}
@@ -1,33 +0,0 @@
import { useMemo, type ReactNode } from "react";
import { Animated, type ViewProps } from "react-native";
import { useKeyboardAnimation } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
interface KeyboardTranslateViewProps extends ViewProps {
children: ReactNode;
enabled?: boolean;
}
export function KeyboardTranslateView({
children,
enabled = true,
style,
...props
}: KeyboardTranslateViewProps) {
const insets = useSafeAreaInsets();
const { height, progress } = useKeyboardAnimation();
const translateY = useMemo(
() => Animated.add(height, Animated.multiply(progress, insets.bottom)),
[height, insets.bottom, progress],
);
const keyboardStyle = useMemo(
() => ({ transform: [{ translateY: enabled ? translateY : 0 }] }),
[enabled, translateY],
);
return (
<Animated.View style={[style, keyboardStyle]} {...props}>
{children}
</Animated.View>
);
}
@@ -1,15 +0,0 @@
import type { ReactNode } from "react";
import { View, type ViewProps } from "react-native";
interface KeyboardTranslateViewProps extends ViewProps {
children: ReactNode;
enabled?: boolean;
}
export function KeyboardTranslateView({
children,
enabled: _enabled,
...props
}: KeyboardTranslateViewProps) {
return <View {...props}>{children}</View>;
}
@@ -16,7 +16,7 @@ import {
} from "@getpaseo/protocol/terminal-input-mode";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardShiftStyle } from "@/keyboard/shift";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
@@ -22,7 +22,7 @@ import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { useKeyboardShift } from "@/keyboard/shift";
import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -1,3 +1,4 @@
import { KeyboardTranslateView } from "@/keyboard/shift";
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -445,25 +446,27 @@ export function WorkspaceSetupDialog() {
desktopMaxWidth={640}
>
<FileDropZone style={styles.section}>
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
isPaneFocused={true}
onSubmitMessage={handleCreateChatAgent}
isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true}
textSource={chatDraft.textSource}
onChangeText={chatDraft.editText}
textReplacement={chatDraft.textReplacement}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
/>
<KeyboardTranslateView>
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
isPaneFocused={true}
onSubmitMessage={handleCreateChatAgent}
isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true}
textSource={chatDraft.textSource}
onChangeText={chatDraft.editText}
textReplacement={chatDraft.textReplacement}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
/>
</KeyboardTranslateView>
</FileDropZone>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}