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
@@ -5,7 +5,7 @@ import {
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { useKeyboardShift } from "@/keyboard/shift";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
beginDrag,
@@ -22,8 +22,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useSettledKeyboardShift } from "@/hooks/keyboard-shift-context";
import { resolveStreamKeyboardInset } from "@/hooks/keyboard-shift-policy";
import { useKeyboardStreamInset } from "@/keyboard/shift";
import { useRevisedHistoryRows } from "./history-row-revision";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
@@ -105,7 +104,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
const settledKeyboardShift = useSettledKeyboardShift();
const streamKeyboardInset = useKeyboardStreamInset();
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const isNativeViewportSettlingRef = useRef(false);
@@ -275,14 +274,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
? undefined
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
const streamKeyboardInset = useMemo(
() =>
resolveStreamKeyboardInset({
platform: Platform.OS === "ios" ? "ios" : "android",
settledShift: settledKeyboardShift,
}),
[settledKeyboardShift],
);
const listContentContainerStyle = useMemo(
() => [
baseListContentContainerStyle,
+1 -1
View File
@@ -88,7 +88,7 @@ import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
@@ -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} />;
}
@@ -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}
@@ -0,0 +1,150 @@
import { ComposerDockBackground } from "./internal/background";
export { ComposerDockBackground } from "./internal/background";
import { ScrollView } from "@/components/ui/scroll-view";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { createContext, useCallback, useContext, type ReactNode } from "react";
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
useDerivedValue,
useAnimatedReaction,
type SharedValue,
} from "react-native-reanimated";
import { useKeyboardShift } from "@/keyboard/shift";
import { updateComposerCapacity, type ComposerCapacity } from "./internal/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. */
function ComposerViewport({
children,
onLayout,
bottomInset = 0,
centered = false,
...props
}: ComposerViewportProps) {
const measuredHeight = useSharedValue(0);
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
const { layoutShift } = useKeyboardShift();
useAnimatedReaction(
() => ({
height: measuredHeight.value,
bottomInset,
keyboardShift: layoutShift.value,
centered,
}),
(geometry) => {
if (geometry.height <= 0) return;
sizing.value = updateComposerCapacity(sizing.value, geometry);
},
[],
);
const capacity = useDerivedValue(() => sizing.value?.capacity);
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. */
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]} />
);
}
interface ComposerDockProps {
children: [ReactNode, ReactNode, ReactNode?];
centered?: boolean;
}
/** The stationary viewport, translated surface, and bounded composer are one owner. */
export function ComposerDock({
children: [content, composer, overlay],
centered = false,
}: ComposerDockProps) {
const insets = useSafeAreaInsets();
// Preserve the existing centered form's visual balance on tablets.
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
if (centered) {
return (
<ComposerViewport
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
bottomInset={bottomInset}
centered
>
<KeyboardTranslateView style={dockStyles.centered}>
<ComposerViewportContent style={dockStyles.composer}>
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
{content}
</ScrollView>
<ComposerViewportContent style={dockStyles.centeredComposer}>
{composer}
</ComposerViewportContent>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
</ComposerViewport>
);
}
return (
<ComposerViewport style={dockStyles.viewport}>
<KeyboardTranslateView style={dockStyles.surface}>
<View
testID="composer-dock-content"
collapsable={false}
style={dockStyles.content}
pointerEvents="box-none"
>
{/* A responder ancestor intercepts native scroll drags on Android Fabric.
Only unclaimed background touches may reach this sibling. */}
<ComposerDockBackground style={StyleSheet.absoluteFill} />
<View style={dockStyles.content} pointerEvents="box-none">
{content}
</View>
</View>
<ComposerViewportContent style={dockStyles.composer}>
<View style={[dockStyles.composer, { paddingBottom: insets.bottom }]}>{composer}</View>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
</ComposerViewport>
);
}
const dockStyles = StyleSheet.create({
viewport: { flex: 1, overflow: "hidden" },
surface: { flex: 1 },
content: { flex: 1, justifyContent: "flex-end" },
composer: { width: "100%", flexShrink: 1 },
centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" },
centered: { flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH },
// Reserve the composer's own capped height before the setup scroll view shrinks.
centeredComposer: { width: "100%", flexShrink: 0 },
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
});
+51
View File
@@ -0,0 +1,51 @@
export { ComposerDockBackground } from "./internal/background";
import { ScrollView } from "@/components/ui/scroll-view";
import type { ReactNode } from "react";
import { View, StyleSheet } from "react-native";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
interface ComposerDockProps {
children: [ReactNode, ReactNode, ReactNode?];
centered?: boolean;
}
export function ComposerDock({
children: [content, composer, overlay],
centered = false,
}: ComposerDockProps) {
if (centered) {
return (
<View style={styles.centered}>
<View style={styles.form}>
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
{content}
</ScrollView>
<View style={styles.centeredComposer}>{composer}</View>
{overlay}
</View>
</View>
);
}
return (
<View style={styles.surface}>
<View style={styles.content}>{content}</View>
<View style={styles.composer}>{composer}</View>
{overlay}
</View>
);
}
const styles = StyleSheet.create({
surface: { flex: 1, overflow: "hidden" },
content: { flex: 1, justifyContent: "flex-end" },
composer: { width: "100%", flexShrink: 1 },
centered: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingBottom: HEADER_INNER_HEIGHT + 24,
},
form: { flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH },
centeredComposer: { flexShrink: 0 },
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
});
@@ -0,0 +1,6 @@
import { Keyboard, Pressable, type ViewProps } from "react-native";
/** Use behind scrollable content as a sibling, never as its responder ancestor. */
export function ComposerDockBackground(props: ViewProps) {
return <Pressable {...props} accessible={false} onPress={Keyboard.dismiss} />;
}
@@ -0,0 +1,5 @@
import { View, type ViewProps } from "react-native";
export function ComposerDockBackground(props: ViewProps) {
return <View {...props} />;
}
@@ -2,47 +2,37 @@ import { describe, expect, it } from "vitest";
import { resolveComposerCapacity, updateComposerCapacity } from "./capacity";
describe("composer viewport", () => {
it("preserves the editing capacity when the keyboard closes on a docked composer", () => {
it("preserves the editing capacity when the keyboard closes", () => {
const viewport = { height: 582, bottomInset: 24, centered: false };
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }, "retain");
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 }, "retain");
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
expect(open.capacity).toBe(245);
expect(closed.capacity).toBe(open.capacity);
expect(
updateComposerCapacity(closed, { ...viewport, keyboardShift: 250 }, "retain").capacity,
).toBe(303);
});
it("releases the whole viewport when the keyboard closes on a form", () => {
const viewport = { height: 582, bottomInset: 24, centered: false };
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }, "release");
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 }, "release");
expect(open.capacity).toBe(245);
expect(closed.capacity).toBe(553);
expect(
updateComposerCapacity(closed, { ...viewport, keyboardShift: 308 }, "release").capacity,
).toBe(245);
expect(updateComposerCapacity(closed, { ...viewport, keyboardShift: 250 }).capacity).toBe(303);
});
it("remeasures the viewport without forgetting the keyboard reservation", () => {
const open = updateComposerCapacity(
undefined,
{ height: 582, bottomInset: 24, centered: false, keyboardShift: 308 },
"retain",
);
const open = updateComposerCapacity(undefined, {
height: 582,
bottomInset: 24,
centered: false,
keyboardShift: 308,
});
expect(
updateComposerCapacity(
open,
{ height: 650, bottomInset: 24, centered: false, keyboardShift: 0 },
"retain",
).capacity,
updateComposerCapacity(open, {
height: 650,
bottomInset: 24,
centered: false,
keyboardShift: 0,
}).capacity,
).toBe(313);
expect(
updateComposerCapacity(
open,
{ height: 0, bottomInset: 24, centered: false, keyboardShift: 0 },
"retain",
),
updateComposerCapacity(open, {
height: 0,
bottomInset: 24,
centered: false,
keyboardShift: 0,
}),
).toEqual(open);
});
it("leaves five points below the header for a bottom-anchored composer", () => {
@@ -10,14 +10,6 @@ export interface ComposerCapacity {
capacity: number;
}
/**
* What the closed keyboard leaves behind. A docked chat composer retains its last
* editing reservation so a capped draft does not expand over the stream. A form
* that scrolls inside the constrained space releases it so the whole form is
* visible again once the keyboard is gone.
*/
export type KeyboardReservePolicy = "retain" | "release";
export function resolveComposerCapacity(input: ComposerGeometry): number {
"worklet";
// A centered form grows upward by half its height. Reserve both halves so
@@ -30,15 +22,13 @@ export function resolveComposerCapacity(input: ComposerGeometry): number {
export function updateComposerCapacity(
previous: ComposerCapacity | undefined,
input: ComposerGeometry,
policy: KeyboardReservePolicy,
): ComposerCapacity {
"worklet";
if (input.height <= 0 && previous) return previous;
// A subsequent keyboard opening always supplies the next reservation.
// Closing the keyboard moves the composer; it does not enlarge its editing
// capacity. A subsequent keyboard opening supplies the next reservation.
const keyboardReserve =
input.keyboardShift > 0 || policy === "release"
? input.keyboardShift
: (previous?.keyboardReserve ?? 0);
input.keyboardShift > 0 ? input.keyboardShift : (previous?.keyboardReserve ?? 0);
return {
keyboardReserve,
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
@@ -2,9 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
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 { ComposerDock } from "@/composer/dock";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
@@ -329,7 +327,6 @@ export function WorkspaceDraftAgentTab({
onOpenImportSheet,
}: WorkspaceDraftAgentTabProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
workspaceDirectory: w.workspaceDirectory,
@@ -612,11 +609,6 @@ export function WorkspaceDraftAgentTab({
focusInputRef.current = focus;
}, []);
const inputAreaWrapperStyle = useMemo(
() => [animatedStaticStyles.inputAreaWrapper, { paddingBottom: insets.bottom }],
[insets.bottom],
);
const handleDropdownCloseFocus = useCallback(() => {
focusInputRef.current?.();
}, []);
@@ -629,77 +621,74 @@ export function WorkspaceDraftAgentTab({
}),
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
);
return (
<ComposerViewport style={styles.container} bottomInset={insets.bottom} keyboardReserve="retain">
<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>
)}
const dockContent = (
<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>
<KeyboardTranslateView style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
<ComposerViewportContent style={animatedStaticStyles.inputAreaWrapper}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
</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}
<Composer
agentId={tabId}
serverId={serverId}
workspaceId={workspaceId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
textSource={draftInput.textSource}
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}
/>
</ComposerViewportContent>
</KeyboardTranslateView>
</FileDropZone>
</ComposerViewport>
</View>
</ScrollView>
)}
</View>
);
return (
<FileDropZone style={styles.container}>
<ComposerDock>
{dockContent}
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
</View>
</View>
) : null}
<Composer
agentId={tabId}
serverId={serverId}
workspaceId={workspaceId}
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
textSource={draftInput.textSource}
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>
</ComposerDock>
</FileDropZone>
);
}
+2 -9
View File
@@ -48,7 +48,6 @@ import {
type DraftAgentControlsProps,
} from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter";
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
@@ -989,8 +988,6 @@ interface ComposerProps {
agentControls?: DraftAgentControlsProps;
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
externalKeyboardShift?: boolean;
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
isCompactLayout?: boolean;
/**
@@ -1278,7 +1275,6 @@ function ComposerContentImpl({
onAttentionPromptSend,
agentControls,
inputWrapperStyle,
externalKeyboardShift,
isCompactLayout: isCompactLayoutOverride,
inputMode = "chat",
readOnly = false,
@@ -2415,10 +2411,7 @@ function ComposerContentImpl({
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
isMessageInputFocused={isMessageInputFocused}
/>
<KeyboardTranslateView
style={animatedStaticStyles.container}
enabled={!externalKeyboardShift}
>
<View style={animatedStaticStyles.container}>
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
@@ -2516,7 +2509,7 @@ function ComposerContentImpl({
</View>
</View>
</View>
</KeyboardTranslateView>
</View>
</>
);
}
@@ -1,79 +0,0 @@
import { createContext, useCallback, useContext } from "react";
import { View, type LayoutChangeEvent, type ViewProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
useDerivedValue,
useAnimatedReaction,
type SharedValue,
} from "react-native-reanimated";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import {
updateComposerCapacity,
type ComposerCapacity,
type KeyboardReservePolicy,
} from "./capacity";
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
interface ComposerViewportProps extends ViewProps {
bottomInset?: number;
centered?: boolean;
keyboardReserve?: KeyboardReservePolicy;
}
/** Measure the stationary space below the header, outside keyboard translation. */
export function ComposerViewport({
children,
onLayout,
bottomInset = 0,
centered = false,
keyboardReserve = "release",
...props
}: ComposerViewportProps) {
const measuredHeight = useSharedValue(0);
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
const { layoutShift } = useKeyboardShift();
useAnimatedReaction(
() => ({
height: measuredHeight.value,
bottomInset,
keyboardShift: layoutShift.value,
centered,
}),
(geometry) => {
if (geometry.height <= 0) return;
sizing.value = updateComposerCapacity(sizing.value, geometry, keyboardReserve);
},
[keyboardReserve],
);
const capacity = useDerivedValue(() => sizing.value?.capacity);
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]} />
);
}
@@ -1,20 +0,0 @@
import { View, type ViewProps } from "react-native";
interface ComposerViewportProps extends ViewProps {
bottomInset?: number;
centered?: boolean;
keyboardReserve?: "retain" | "release";
}
export function ComposerViewport({
bottomInset: _bottomInset,
centered: _centered,
keyboardReserve: _keyboardReserve,
...props
}: ComposerViewportProps) {
return <View {...props} />;
}
export function ComposerViewportContent(props: ViewProps) {
return <View {...props} />;
}
@@ -20,7 +20,7 @@ import Animated, {
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { InlineReviewThread } from "@/review";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { useKeyboardShift } from "@/keyboard/shift";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { DocumentFileHeader } from "./document-file-header";
import {
+5
View File
@@ -0,0 +1,5 @@
export { KeyboardShiftProvider, useKeyboardShiftStyle } from "./internal/provider";
export { useKeyboardShift, useSettledKeyboardShift } from "./internal/context";
export { KeyboardTranslateView } from "./internal/translate";
export { shouldUseCompactExplorerKeyboardPadding } from "./internal/policy";
export { useKeyboardStreamInset } from "./internal/stream";
@@ -5,7 +5,7 @@ import {
resolveKeyboardShift,
reserveKeyboardLayoutShift,
shouldUseCompactExplorerKeyboardPadding,
} from "./keyboard-shift-policy";
} from "./policy";
describe("keyboard layout reservation", () => {
it("reserves the opening destination before motion and releases it only after closing", () => {
@@ -19,12 +19,8 @@ import {
resolveKeyboardShift,
reserveKeyboardLayoutShift,
shouldReconcileHiddenKeyboardEnd,
} from "@/hooks/keyboard-shift-policy";
import {
KeyboardShiftContext,
SettledKeyboardShiftContext,
useKeyboardShift,
} from "@/hooks/keyboard-shift-context";
} from "./policy";
import { KeyboardShiftContext, SettledKeyboardShiftContext, useKeyboardShift } from "./context";
type KeyboardShiftMode = "translate" | "padding";
@@ -0,0 +1,17 @@
import { useMemo } from "react";
import { Platform } from "react-native";
import { useSettledKeyboardShift } from "./context";
import { resolveStreamKeyboardInset } from "./policy";
/** Preserve the translated inverted list's far-end scroll range only after motion settles. */
export function useKeyboardStreamInset() {
const settledShift = useSettledKeyboardShift();
return useMemo(
() =>
resolveStreamKeyboardInset({
platform: Platform.OS === "ios" ? "ios" : "android",
settledShift,
}),
[settledShift],
);
}
@@ -1,7 +1,7 @@
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";
import { useKeyboardShift } from "./context";
interface KeyboardTranslateViewProps extends ViewProps {
children: ReactNode;
+75 -60
View File
@@ -16,15 +16,13 @@ import React, {
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { shallow, useShallow } from "zustand/shallow";
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 { ComposerDock } from "@/composer/dock";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { RetainedChatContent } from "./retained-chat-content";
@@ -1272,7 +1270,36 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
) : null}
</View>
);
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
const dockContent = (
<View style={styles.contentContainer}>
{streamContent}
{showHistorySyncError ? (
<TimelineSyncErrorCallout isRetrying={isRetryingHistorySync} onRetry={retryTimelineSync} />
) : null}
</View>
);
const dockOverlay = (
<>
{showHistorySyncOverlay ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
<TimelineSyncStatus sync={timelineSync} toast={toast} onDismiss={dismiss} />
</>
);
const dock = (
<ChatSurface disabled={isArchivingCurrentAgent}>
{dockContent}
{composerSection}
{dockOverlay}
</ChatSurface>
);
return (
<RewindComposerRestoreProvider
@@ -1280,45 +1307,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
setText={agentInputDraft.replaceText}
onRewindComplete={handleRewindComplete}
>
<ComposerViewport style={styles.root} keyboardReserve="retain">
<DockedChatSurface disabled={isArchivingCurrentAgent}>
{contentContainer}
{showHistorySyncError ? (
<View style={styles.timelineSyncCalloutRail}>
<View style={styles.timelineSyncCalloutContent}>
<View style={styles.timelineSyncCallout} testID="agent-timeline-sync-error">
<Text style={styles.timelineSyncCalloutText}>
{t("agentPanel.states.timelineSyncFailed")}
</Text>
<Button
size="sm"
variant="secondary"
onPress={retryTimelineSync}
disabled={isRetryingHistorySync}
testID="agent-timeline-sync-retry"
>
{isRetryingHistorySync
? t("agentPanel.states.timelineSyncRetrying")
: t("common.actions.retry")}
</Button>
</View>
</View>
</View>
) : null}
<ComposerViewportContent style={animatedStaticStyles.inputAreaWrapper}>
{composerSection}
</ComposerViewportContent>
{showHistorySyncOverlay ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
<TimelineSyncStatus sync={timelineSync} toast={toast} onDismiss={dismiss} />
</DockedChatSurface>
<View style={styles.root}>
{dock}
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
@@ -1327,18 +1317,52 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
<Text style={styles.archivingSubtitle}>{t("agentPanel.states.archivingSubtitle")}</Text>
</View>
) : null}
</ComposerViewport>
</View>
</RewindComposerRestoreProvider>
);
});
function DockedChatSurface({ children, disabled }: { children: ReactNode; disabled: boolean }) {
function ChatSurface({
children,
disabled,
}: {
children: [ReactNode, ReactNode, ReactNode];
disabled: boolean;
}) {
return (
<KeyboardDock style={styles.container}>
<FileDropZone style={styles.container} disabled={disabled}>
{children}
</FileDropZone>
</KeyboardDock>
<FileDropZone style={styles.container} disabled={disabled}>
<ComposerDock>{children}</ComposerDock>
</FileDropZone>
);
}
function TimelineSyncErrorCallout({
isRetrying,
onRetry,
}: {
isRetrying: boolean;
onRetry: () => void;
}) {
const { t } = useTranslation();
return (
<View style={styles.timelineSyncCalloutRail}>
<View style={styles.timelineSyncCalloutContent}>
<View style={styles.timelineSyncCallout} testID="agent-timeline-sync-error">
<Text style={styles.timelineSyncCalloutText}>
{t("agentPanel.states.timelineSyncFailed")}
</Text>
<Button
size="sm"
variant="secondary"
onPress={onRetry}
disabled={isRetrying}
testID="agent-timeline-sync-retry"
>
{isRetrying ? t("agentPanel.states.timelineSyncRetrying") : t("common.actions.retry")}
</Button>
</View>
</View>
</View>
);
}
@@ -1518,7 +1542,6 @@ function ActiveAgentComposer({
onComposerHeightChange: (height: number) => void;
onMessageSent: () => void;
}) {
const insets = useSafeAreaInsets();
const isCompactFormFactor = useIsCompactFormFactor();
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_FORM_FACTOR_WIDTH,
@@ -1593,18 +1616,12 @@ function ActiveAgentComposer({
],
);
const inputAreaStyle = useMemo(
() => [animatedStaticStyles.inputAreaWrapper, { paddingBottom: insets.bottom }],
[insets.bottom],
);
return (
<View style={inputAreaStyle} onLayout={onInputAreaLayout}>
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
<Composer
agentId={agentId}
serverId={serverId}
workspaceId={workspaceId}
externalKeyboardShift
blurOnSubmit={isNative}
isPaneFocused={isPaneFocused}
textSource={agentInputDraft.textSource}
@@ -1715,8 +1732,6 @@ const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
backgroundColor: theme.colors.surface0,
// KeyboardDock translates the chat surface while the keyboard moves; clip it at the header edge.
overflow: "hidden",
},
container: {
flex: 1,
+113 -124
View File
@@ -6,20 +6,17 @@ import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/
import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactElement, RefObject } from "react";
import type { ReactElement, ReactNode, RefObject } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Pressable, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import type { PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { createNameId } from "mnemonic-id";
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 { ComposerDock } from "@/composer/dock";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import {
resolveComposerAttachmentSubmitFormat,
@@ -36,7 +33,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
@@ -689,8 +686,13 @@ function IsolationPickerTrigger({
// Wraps a single argument control in the mobile vertical stack. On desktop the
// controls are laid out in one horizontal row, so no per-control wrapper is used.
// Decorative padding must not block the dock background from iOS hit testing.
function FormRow({ children }: { children: React.ReactNode }) {
return <View style={styles.row}>{children}</View>;
return (
<View style={styles.row} pointerEvents="box-none">
{children}
</View>
);
}
interface WorkspaceIsolationState {
@@ -740,13 +742,6 @@ function isolationLabel(t: TFunction, isolation: "local" | "worktree"): string {
: t("newWorkspace.isolation.local");
}
function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
if (input.isCompact) {
return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }];
}
return [styles.content, styles.contentCentered, { paddingBottom: input.insetBottom }];
}
function normalizeBranchDetails(
data: { branchDetails?: BranchPickerDetail[]; branches?: string[] } | undefined,
): BranchPickerDetail[] {
@@ -1602,23 +1597,27 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
);
return isCompact ? (
<View testID="new-workspace-ref-picker-row" style={styles.formStack}>
<View testID="new-workspace-ref-picker-row" style={styles.formStack} pointerEvents="box-none">
<FormRow>{projectControl}</FormRow>
{hostControl ? <FormRow>{hostControl}</FormRow> : null}
{isolationControl ? <FormRow>{isolationControl}</FormRow> : null}
{baseControl ? <FormRow>{baseControl}</FormRow> : null}
<FormRow>{launchControl}</FormRow>
{/* Keep fixed stack height without separating the visible controls. */}
{isolationControl ? null : <View style={styles.baseSpacer} />}
{baseControl ? null : <View style={styles.baseSpacer} />}
{isolationControl ? null : <View style={styles.baseSpacer} pointerEvents="none" />}
{baseControl ? null : <View style={styles.baseSpacer} pointerEvents="none" />}
</View>
) : (
<View testID="new-workspace-ref-picker-row" style={styles.formStackDesktop}>
<View
testID="new-workspace-ref-picker-row"
style={styles.formStackDesktop}
pointerEvents="box-none"
>
{projectControl}
{hostControl}
{isolationControl}
{baseControl}
<View style={styles.launchSpacer} />
<View style={styles.launchSpacer} pointerEvents="none" />
{launchControl}
</View>
);
@@ -1634,7 +1633,6 @@ export function NewWorkspaceScreen({
const queryClient = useQueryClient();
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor();
const toast = useToast();
const mergeWorkspaces = useCallback(
@@ -2290,15 +2288,6 @@ 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: contentBottomInset }),
[isCompact, contentBottomInset],
);
const agentControlsWithDisabled = useMemo(
() =>
composerState
@@ -2379,101 +2368,108 @@ export function NewWorkspaceScreen({
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
const composer = isTerminalLaunch ? (
<Composer
key="terminal"
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}
textSource={terminalTextSource}
onChangeText={setTerminalPromptText}
textReplacement={terminalTextReplacement}
attachments={NO_TERMINAL_ATTACHMENTS}
onChangeAttachments={noopChangeAttachments}
cwd={selectedSourceDirectory ?? ""}
clearDraft={noopClearDraft}
autoFocus={terminalTakesPrompt}
autoFocusKey={launchFocusKey}
/>
) : (
<Composer
key="chat"
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}
textSource={chatDraft.textSource}
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}
/>
);
return (
<FileDropZone style={styles.container}>
<ScreenHeader left={screenHeaderLeft} borderless />
<ComposerViewport style={contentStyle} bottomInset={contentBottomInset} centered={!isCompact}>
<View style={styles.content}>
<TitlebarDragRegion />
<KeyboardTranslateView style={animatedStaticStyles.centered}>
<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}
textSource={terminalTextSource}
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}
textSource={chatDraft.textSource}
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>
</ComposerViewport>
<NewWorkspaceLayout
isCompact={isCompact}
title={t("newWorkspace.title")}
formStack={formStack}
>
{composer}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</NewWorkspaceLayout>
</View>
</FileDropZone>
);
}
const animatedStaticStyles = RNStyleSheet.create({
centered: {
flexShrink: 1,
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
},
form: {
flexShrink: 1,
},
setup: {
flexGrow: 0,
flexShrink: 1,
},
});
function NewWorkspaceLayout({
isCompact,
title,
formStack,
children,
}: {
isCompact: boolean;
title: string;
formStack: ReactNode;
children: ReactNode;
}) {
const setupFields = (
<>
<View style={styles.composerTitleContainer} pointerEvents="none">
<Text style={styles.composerTitle}>{title}</Text>
</View>
{formStack}
</>
);
return (
<ComposerDock centered={!isCompact}>
{setupFields}
{children}
</ComposerDock>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
@@ -2484,13 +2480,6 @@ const styles = StyleSheet.create((theme) => ({
content: {
position: "relative",
flex: 1,
alignItems: "center",
},
contentCentered: {
justifyContent: "center",
},
contentCompact: {
justifyContent: "flex-end",
},
composerTitleContainer: {
marginBottom: theme.spacing[8],