diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index de0c090f9399..d4bf3ae3e7d0 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -13,9 +13,10 @@ import { AppBar } from "../../components/ui/AppBar"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useAtomMount } from "@effect/atom-react"; -import { useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { useMemo, useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useKeyboardShowing } from "../../lib/keyboardShowing"; import { AppText as Text } from "../../components/ui/AppText"; import { TerminalContextSheet } from "./TerminalContextSheet"; import { hasNativeTerminalSurface, supportsNativeTerminalOutput } from "./nativeTerminalModule"; @@ -25,7 +26,6 @@ import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/reactivity"; import { KeyboardController, - KeyboardEvents, KeyboardStickyView, useKeyboardState, } from "react-native-keyboard-controller"; @@ -354,26 +354,6 @@ function readLiveTerminalSessions( })); } -// `useKeyboardState` only flips on willShow and didHide; the accessory must -// leave as soon as the keyboard starts hiding, so it follows the will-events. -let keyboardShowing = KeyboardController.isVisible(); -function subscribeKeyboardShowing(onChange: () => void) { - keyboardShowing = KeyboardController.isVisible(); - const willShow = KeyboardEvents.addListener("keyboardWillShow", () => { - keyboardShowing = true; - onChange(); - }); - const willHide = KeyboardEvents.addListener("keyboardWillHide", () => { - keyboardShowing = false; - onChange(); - }); - return () => { - willShow.remove(); - willHide.remove(); - }; -} -const getKeyboardShowing = () => keyboardShowing; - function ThreadTerminal(props: { readonly params: ThreadTerminalRouteScreenProps["route"]["params"]; readonly routeEnvironmentId: EnvironmentId | null; @@ -594,7 +574,7 @@ function ThreadTerminal(props: { height: state.height, isVisible: state.isVisible, })); - const keyboardShowing = useSyncExternalStore(subscribeKeyboardShowing, getKeyboardShowing); + const keyboardShowing = useKeyboardShowing(); // The accessory follows the keyboard, except that the dismiss and show // buttons flip it ahead of the keyboard animation. const [accessory, setAccessory] = useState({ keyboardShowing, dismissed: false }); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index df5aca19dec1..59ee4347d174 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -32,6 +32,7 @@ import { useEffect, useEffectEvent, useRef, useState, type RefObject } from "rea import { Keyboard, Platform, View, type ViewStyle } from "react-native"; import { composerImageGallery } from "../../lib/composerImageGallery"; import { filePreviewGallery } from "../../lib/filePreviewGallery"; +import { useKeyboardShowing } from "../../lib/keyboardShowing"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { composerAttachmentUploadBlockReason, @@ -105,6 +106,13 @@ import { ComposerDictationToolbar, } from "../voice-input/ComposerDictationControl"; import { useVoiceInputController } from "../voice-input/useVoiceInputController"; +import { + EMPTY_COMPACT_COMPOSER_SLOT, + compactSlotAfterLabelLayout, + compactSlotAfterRowLayout, + isWideComposerRow, + resolveCompactComposerLayout, +} from "./compactComposerLayout"; import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation"; import type { ExistingThreadSettingsRouteParams } from "./ThreadSettingsSheet"; import { @@ -114,6 +122,9 @@ import { export const COMPOSER_COLLAPSED_CHROME = 60; +// Only Android gates focus on the keyboard, so iOS never subscribes. +const useAndroidKeyboardShowing = Platform.OS === "android" ? useKeyboardShowing : () => false; + const COLLAPSED_EDITOR_DEFAULT_INSET = 6; // iOS puts line-height slack above glyphs. DM Sans's lowercase midline is // descender (0.31em) + half x-height (0.504em) above the line bottom. @@ -128,9 +139,22 @@ function collapsedEditorInset(text: ScaledTextRole): number { return Math.max(0, Math.round(COLLAPSED_EDITOR_DEFAULT_INSET - shift)); } -export function useCompactComposerEditor(text: ScaledTextRole, value: string) { +export function useCompactComposerEditor( + text: ScaledTextRole, + value: string, + state: { + readonly isFocused: boolean; + readonly keepsSettingsOpen: boolean; + readonly rowAttachmentCount: number; + }, +) { const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); - const [compactWidth, setCompactWidth] = useState(0); + const [slot, setSlot] = useState(EMPTY_COMPACT_COMPOSER_SLOT); + const keyboardShowing = useAndroidKeyboardShowing(); + // Only a keyboard that showed since focus and then hid closes the card, so a refocus that + // is still waiting for its keyboard (after the model sheet) keeps it open. + const [keyboardShownSinceFocus, setKeyboardShownSinceFocus] = useState(false); + if (keyboardShowing && !keyboardShownSinceFocus) setKeyboardShownSinceFocus(true); const contentHeight = contentSize.height; const rowWidth = useSharedValue(0); const editorX = useSharedValue(0); @@ -138,16 +162,19 @@ export function useCompactComposerEditor(text: ScaledTextRole, value: string) { const inset = Platform.OS === "android" ? COLLAPSED_EDITOR_DEFAULT_INSET : collapsedEditorInset(text); const minimum = Math.max(INLINE_EDITOR_HEIGHT, Math.ceil(text.lineHeight) + 2 * inset); + const { isExpanded, modelIconOnly, labelShownAtRest } = resolveCompactComposerLayout({ + ...state, + slot, + keyboardDismissed: keyboardShownSinceFocus && !keyboardShowing, + value, + contentSize, + lineHeight: text.lineHeight, + fontSize: text.fontSize, + }); const height = Math.max( minimum, Math.min(text.lineHeight * 4 + 2 * inset, contentHeight + 2 * inset), ); - // A line that fits the expanded editor may still overflow its compact slot. - // Compare its measured width too so widening cannot trigger a collapse loop. - const isExpanded = - value.length > 0 && - (contentHeight > text.lineHeight * 1.5 || - (compactWidth > 0 && contentSize.width > compactWidth)); const compactRowHeight = Math.max(44, minimum); const editorStyle = useAnimatedStyle(() => ({ position: "absolute", @@ -160,20 +187,42 @@ export function useCompactComposerEditor(text: ScaledTextRole, value: string) { })); return { isExpanded, + modelIconOnly, + rowWidth: slot.rowWidth, containerStyle: { height: isExpanded ? height + 52 : compactRowHeight }, editorStyle, onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => { - rowWidth.set(event.nativeEvent.layout.width); + const width = event.nativeEvent.layout.width; + rowWidth.set(width); + setSlot((current) => compactSlotAfterRowLayout(current, width)); }, onEditorLayout: (event: { nativeEvent: { layout: { x: number; width: number } } }) => { // The expanded toolbar gives this slot to the model control. Keep the // compact geometry for the return transition, rather than recording zero. if (!isExpanded) { - editorX.set(event.nativeEvent.layout.x); - editorWidth.set(event.nativeEvent.layout.width); - setCompactWidth(Math.max(0, event.nativeEvent.layout.width - 8)); + const { x, width } = event.nativeEvent.layout; + editorX.set(x); + editorWidth.set(width); + const compactWidth = Math.max(0, width - 8); + const attachmentCount = state.rowAttachmentCount; + setSlot((current) => + current.compactWidth === compactWidth && current.attachmentCount === attachmentCount + ? current + : { ...current, compactWidth, attachmentCount }, + ); } }, + onEditorFocus: () => setKeyboardShownSinceFocus(false), + onModelControlLayout: (event: { nativeEvent: { layout: { width: number } } }) => { + const { width } = event.nativeEvent.layout; + setSlot((current) => + compactSlotAfterLabelLayout(current, { + width, + isExpanded, + shownAtRest: labelShownAtRest, + }), + ); + }, editorProps: { singleLineCentered: !isExpanded, scrollEnabled: Platform.OS === "android" || contentHeight >= text.lineHeight * 4, @@ -564,9 +613,13 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const { setComposerEditing } = useAdaptiveWorkspaceLayout(); const usesLargeControls = useSidebarWorkspaceTabs() === "picker" || (Platform.OS === "ios" && Platform.isPad); - const [compactRowWidth, setCompactRowWidth] = useState(0); - const compactAttachmentLimit = compactRowWidth >= 500 ? 3 : 1; - const compactEditor = useCompactComposerEditor(bodyText, draftMessage); + const compactEditor = useCompactComposerEditor(bodyText, draftMessage, { + // Dictation hides the editor and keeps focus, so the card would be an empty band. + isFocused: isFocused && !isVoiceInputPresented, + keepsSettingsOpen: settingsSheetPresentation.keepsComposerExpanded, + rowAttachmentCount: isExpanded ? 0 : stripAttachments.length, + }); + const compactAttachmentLimit = isWideComposerRow(compactEditor.rowWidth) ? 3 : 1; const showsCompactDictation = isVoiceInputPresented; const isToolbarVisible = isVoiceInputPresented; const hasEditingAttachments = @@ -628,6 +681,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = () => { setIsFocused(true); + compactEditor.onEditorFocus(); settingsSheetPresentation.onEditorFocusChange(true); onEditorFocusChange?.(true); setComposerEditing(true); @@ -767,7 +821,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ) : null; const modelControl = ( - + @@ -860,12 +914,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps }} > {isExpanded ? editingAttachments : null} - { - compactEditor.onRowLayout(event); - setCompactRowWidth(event.nativeEvent.layout.width); - }} - > + ); @@ -3280,7 +3282,7 @@ export const ThreadFeed = memo(function ThreadFeed({ title="No conversation yet" detail="Ask the agent to inspect the repo, run a command, or continue the active thread." topInset={topContentInset} - bottomInset={bottomContentInset} + bottomInset={placeholderBottomInset} horizontalPadding={horizontalPadding} /> diff --git a/apps/mobile/src/features/threads/compactComposerLayout.test.ts b/apps/mobile/src/features/threads/compactComposerLayout.test.ts new file mode 100644 index 000000000000..00076b91352f --- /dev/null +++ b/apps/mobile/src/features/threads/compactComposerLayout.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + EMPTY_COMPACT_COMPOSER_SLOT, + compactSlotAfterLabelLayout, + compactSlotAfterRowLayout, + resolveCompactComposerLayout, +} from "./compactComposerLayout"; + +const LINE_HEIGHT = 22; +const FONT_SIZE = 16; +const NARROW = 360; +const WIDE = 700; + +function composer( + input: { + readonly rowWidth?: number; + readonly isFocused?: boolean; + readonly keyboardDismissed?: boolean; + readonly keepsSettingsOpen?: boolean; + readonly value?: string; + readonly contentSize?: { readonly width: number; readonly height: number }; + readonly compactWidth?: number; + readonly attachmentCount?: number; + readonly measuredAttachmentCount?: number; + } = {}, +) { + const { isExpanded, modelIconOnly } = resolveCompactComposerLayout({ + slot: { + ...EMPTY_COMPACT_COMPOSER_SLOT, + rowWidth: input.rowWidth ?? NARROW, + compactWidth: input.compactWidth ?? 200, + attachmentCount: input.measuredAttachmentCount ?? input.attachmentCount ?? 0, + }, + isFocused: input.isFocused ?? false, + keyboardDismissed: input.keyboardDismissed ?? false, + keepsSettingsOpen: input.keepsSettingsOpen ?? false, + value: input.value ?? "", + contentSize: input.contentSize ?? { width: 0, height: LINE_HEIGHT }, + lineHeight: LINE_HEIGHT, + fontSize: FONT_SIZE, + rowAttachmentCount: input.attachmentCount ?? 0, + }); + return { isExpanded, modelIconOnly }; +} + +describe("compact composer layout", () => { + it("opens the card with the model label when a narrow composer is focused", () => { + expect(composer()).toEqual({ isExpanded: false, modelIconOnly: true }); + expect(composer({ isFocused: true })).toEqual({ isExpanded: true, modelIconOnly: false }); + // Android's Back hides the keyboard but leaves the editor focused. + expect(composer({ isFocused: true, keyboardDismissed: true }).isExpanded).toBe(false); + expect(composer({ keepsSettingsOpen: true })).toEqual({ + isExpanded: true, + modelIconOnly: false, + }); + }); + + it("shows the model label at rest on a wide row and keeps focus in the row", () => { + expect(composer({ rowWidth: WIDE })).toEqual({ isExpanded: false, modelIconOnly: false }); + expect(composer({ rowWidth: WIDE, isFocused: true }).isExpanded).toBe(false); + expect(composer({ rowWidth: WIDE, keepsSettingsOpen: true }).isExpanded).toBe(false); + expect(composer({ rowWidth: WIDE, attachmentCount: 1 }).modelIconOnly).toBe(true); + }); + + it("opens a wide row's card about one character before the line fills its slot", () => { + const draft = { rowWidth: WIDE, value: "draft", compactWidth: 400 }; + expect(composer({ ...draft, contentSize: { width: 380, height: LINE_HEIGHT } })).toEqual({ + isExpanded: false, + modelIconOnly: false, + }); + expect(composer({ ...draft, contentSize: { width: 390, height: LINE_HEIGHT } })).toEqual({ + isExpanded: true, + modelIconOnly: false, + }); + expect( + composer({ ...draft, contentSize: { width: 100, height: LINE_HEIGHT * 2 } }).isExpanded, + ).toBe(true); + }); + + it("re-measures the compact slot when the row widens so the card can close", () => { + const narrowSlot = { ...EMPTY_COMPACT_COMPOSER_SLOT, rowWidth: NARROW, compactWidth: 200 }; + const line = { value: "a single line draft", contentSize: { width: 300, height: LINE_HEIGHT } }; + expect(composer({ ...line, rowWidth: NARROW, compactWidth: narrowSlot.compactWidth })).toEqual({ + isExpanded: true, + modelIconOnly: false, + }); + + const widened = compactSlotAfterRowLayout(narrowSlot, WIDE); + expect(compactSlotAfterRowLayout(widened, WIDE)).toBe(widened); + expect(composer({ ...line, rowWidth: WIDE, compactWidth: widened.compactWidth })).toEqual({ + isExpanded: false, + modelIconOnly: false, + }); + }); + + it("re-measures a wide row's slot when the label shrinks while the card is open", () => { + const line = { + rowWidth: WIDE, + value: "a long draft", + contentSize: { width: 330, height: LINE_HEIGHT }, + }; + const label = { isExpanded: true, shownAtRest: true }; + let slot = { ...EMPTY_COMPACT_COMPOSER_SLOT, rowWidth: WIDE, compactWidth: 320 }; + slot = compactSlotAfterLabelLayout(slot, { ...label, width: 200, isExpanded: false }); + slot = compactSlotAfterLabelLayout(slot, { ...label, width: 200 }); + expect(composer({ ...line, compactWidth: slot.compactWidth }).isExpanded).toBe(true); + + // Opening and closing the card resizes a truncated label without resetting the slot, + // and a label that grows with the card open only narrows a slot the draft already fills. + slot = compactSlotAfterLabelLayout(slot, { ...label, width: 240, isExpanded: false }); + slot = compactSlotAfterLabelLayout(slot, { ...label, width: 200 }); + slot = compactSlotAfterLabelLayout(slot, { ...label, width: 220 }); + expect(slot.compactWidth).toBe(320); + + slot = compactSlotAfterLabelLayout(slot, { ...label, width: 160 }); + expect(composer({ ...line, compactWidth: slot.compactWidth }).isExpanded).toBe(false); + }); + + it("re-measures a narrow row's slot when its thumbnail is removed with the card open", () => { + const line = { value: "a short line", contentSize: { width: 150, height: LINE_HEIGHT } }; + const measuredWithThumbnail = { compactWidth: 138, measuredAttachmentCount: 1 }; + expect(composer({ ...line, ...measuredWithThumbnail, attachmentCount: 1 }).isExpanded).toBe( + true, + ); + expect(composer({ ...line, ...measuredWithThumbnail, attachmentCount: 0 }).isExpanded).toBe( + false, + ); + expect(composer({ ...line, compactWidth: 172 }).isExpanded).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/compactComposerLayout.ts b/apps/mobile/src/features/threads/compactComposerLayout.ts new file mode 100644 index 000000000000..ea15edc15757 --- /dev/null +++ b/apps/mobile/src/features/threads/compactComposerLayout.ts @@ -0,0 +1,84 @@ +const WIDE_COMPOSER_ROW_WIDTH = 500; + +export function isWideComposerRow(rowWidth: number): boolean { + return rowWidth >= WIDE_COMPOSER_ROW_WIDTH; +} + +interface CompactComposerSlot { + readonly rowWidth: number; + /** The editor's collapsed width, or 0 until it is measured again. */ + readonly compactWidth: number; + /** Thumbnails in the row when `compactWidth` was measured; it is stale for any other count. */ + readonly attachmentCount: number; + readonly label: { readonly width: number; readonly isExpanded: boolean }; +} + +export const EMPTY_COMPACT_COMPOSER_SLOT: CompactComposerSlot = { + rowWidth: 0, + compactWidth: 0, + attachmentCount: 0, + label: { width: 0, isExpanded: false }, +}; + +/** A new row width forgets the compact slot so the next collapsed layout re-measures it. */ +export function compactSlotAfterRowLayout( + slot: CompactComposerSlot, + rowWidth: number, +): CompactComposerSlot { + return rowWidth === slot.rowWidth ? slot : { ...slot, rowWidth, compactWidth: 0 }; +} + +/** + * A label shown at rest shares the row with the compact slot, which is only measured while + * collapsed, so a label that shrinks while the card stays open forgets it; a wider label only + * narrows the slot, which keeps an open card correct. Layouts that open or close the card + * only record the width: resetting on them would loop. + */ +export function compactSlotAfterLabelLayout( + slot: CompactComposerSlot, + label: { readonly width: number; readonly isExpanded: boolean; readonly shownAtRest: boolean }, +): CompactComposerSlot { + const previous = slot.label; + if (previous.width === label.width && previous.isExpanded === label.isExpanded) return slot; + const shrankWhileOpen = + label.shownAtRest && label.isExpanded && previous.isExpanded && label.width < previous.width; + return { + ...slot, + label: { width: label.width, isExpanded: label.isExpanded }, + compactWidth: shrankWhileOpen ? 0 : slot.compactWidth, + }; +} + +export function resolveCompactComposerLayout(input: { + readonly slot: CompactComposerSlot; + readonly isFocused: boolean; + /** Android's Back hides the keyboard without blurring the editor. */ + readonly keyboardDismissed: boolean; + readonly keepsSettingsOpen: boolean; + readonly value: string; + readonly contentSize: { readonly width: number; readonly height: number }; + readonly lineHeight: number; + readonly fontSize: number; + /** Thumbnails shown inside the collapsed row, beside the model control. */ + readonly rowAttachmentCount: number; +}): { + readonly isExpanded: boolean; + readonly modelIconOnly: boolean; + readonly labelShownAtRest: boolean; +} { + const { rowWidth } = input.slot; + const compactWidth = + input.slot.attachmentCount === input.rowAttachmentCount ? input.slot.compactWidth : 0; + const isWide = isWideComposerRow(rowWidth); + const opensOnFocus = + rowWidth > 0 && + !isWide && + ((input.isFocused && !input.keyboardDismissed) || input.keepsSettingsOpen); + const wraps = input.contentSize.height > input.lineHeight * 1.5; + // A line that fits the expanded editor may still overflow its compact slot. + // Compare its measured width too, a character early, so widening cannot loop. + const fillsSlot = compactWidth > 0 && input.contentSize.width > compactWidth - input.fontSize; + const isExpanded = opensOnFocus || (input.value.length > 0 && (wraps || fillsSlot)); + const labelShownAtRest = isWide && input.rowAttachmentCount === 0; + return { isExpanded, modelIconOnly: !isExpanded && !labelShownAtRest, labelShownAtRest }; +} diff --git a/apps/mobile/src/lib/keyboardShowing.ts b/apps/mobile/src/lib/keyboardShowing.ts new file mode 100644 index 000000000000..7515d401f4b2 --- /dev/null +++ b/apps/mobile/src/lib/keyboardShowing.ts @@ -0,0 +1,26 @@ +import { useSyncExternalStore } from "react"; +import { KeyboardController, KeyboardEvents } from "react-native-keyboard-controller"; + +// `useKeyboardState` only flips on willShow and didHide; this follows the +// will-events so UI can leave as soon as the keyboard starts hiding. +let keyboardShowing = KeyboardController.isVisible(); +function subscribeKeyboardShowing(onChange: () => void) { + keyboardShowing = KeyboardController.isVisible(); + const willShow = KeyboardEvents.addListener("keyboardWillShow", () => { + keyboardShowing = true; + onChange(); + }); + const willHide = KeyboardEvents.addListener("keyboardWillHide", () => { + keyboardShowing = false; + onChange(); + }); + return () => { + willShow.remove(); + willHide.remove(); + }; +} +const getKeyboardShowing = () => keyboardShowing; + +export function useKeyboardShowing(): boolean { + return useSyncExternalStore(subscribeKeyboardShowing, getKeyboardShowing); +}