Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
81d3b5e
feat(mobile): narrow composers open the card on tap
flamboh Oct 23, 2026
6e814d5
refactor(mobile): share the composer's model display label
flamboh Oct 23, 2026
a0613f3
fix(mobile): re-measure the composer row after it is resized
flamboh Oct 23, 2026
7f7e08a
refactor(mobile): leave model label shortening to its own change
flamboh Oct 23, 2026
59008e9
fix(mobile): close the composer card on Android Back and wait for the…
flamboh Oct 24, 2026
7606cda
fix(mobile): pasting a long line no longer flashes the composer card
flamboh Oct 24, 2026
0c4d667
fix(mobile): Android composer card closes with the keyboard
flamboh Oct 24, 2026
be67a40
fix(mobile): open the card for wrapped drafts when an attachment alre…
flamboh Oct 24, 2026
00c7311
fix(mobile): keep the empty-thread message still when the composer ca…
flamboh Oct 25, 2026
0d64a22
fix(mobile): leave room for the floating status control under the emp…
flamboh Oct 25, 2026
d22a6d4
chore(mobile): justify the keyboard listener effect for the effect bu…
flamboh Oct 26, 2026
3890314
feat(mobile): the model name slides out of the icon and the draft sli…
flamboh Oct 26, 2026
737bfb1
fix(mobile): wide composers keep the model label and open the card as…
flamboh Oct 26, 2026
902feeb
fix(mobile): integrate current composer layout into narrow card behavior
maria-rcks Oct 26, 2026
2d2443f
Merge branch 'main' into t3/composer-card-on-tap
flamboh Oct 27, 2026
a933966
test(mobile): takeover tests for the compact composer card
juliusmarminge Oct 27, 2026
4a5b132
refactor(mobile): compact composer resolves its layout in one place
juliusmarminge Oct 27, 2026
9113191
Merge remote-tracking branch 'origin/main' into pr-23986
juliusmarminge Oct 27, 2026
de1ddf8
fix(mobile): wide composers re-measure when the model label resizes w…
juliusmarminge Oct 27, 2026
267b0dd
fix(mobile): dictation from a focused narrow composer keeps the row h…
juliusmarminge Oct 27, 2026
52ad83f
fix(mobile): a wider model label no longer bounces an open wide-row card
juliusmarminge Oct 27, 2026
9bb5ad2
fix(mobile): removing a thumbnail with the card open no longer strand…
juliusmarminge Oct 27, 2026
a50ca1c
fix(mobile): the Android card no longer closes while focus returns fr…
juliusmarminge Oct 27, 2026
033153d
Merge remote-tracking branch 'origin/main' into pr-23986
juliusmarminge Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 3 additions & 23 deletions apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down
95 changes: 72 additions & 23 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand All @@ -128,26 +139,42 @@ 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);
const editorWidth = useSharedValue(0);
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",
Expand All @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -767,7 +821,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
) : null;

const modelControl = (
<View className="min-w-0 shrink">
<View className="min-w-0 shrink" onLayout={compactEditor.onModelControlLayout}>
<ComposerModelControl
size={usesLargeControls ? "large" : "regular"}
modelOption={currentModelOption}
Expand All @@ -776,7 +830,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
reportedSelection={props.reportedModelSelection}
prompt={draftMessage}
surfaceColor={composerPanel}
iconOnly={!compactEditor.isExpanded}
iconOnly={compactEditor.modelIconOnly}
onPress={openSettings}
/>
</View>
Expand Down Expand Up @@ -860,12 +914,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
}}
>
{isExpanded ? editingAttachments : null}
<View
onLayout={(event) => {
compactEditor.onRowLayout(event);
setCompactRowWidth(event.nativeEvent.layout.width);
}}
>
<View onLayout={compactEditor.onRowLayout}>
<ComposerDictationDraftContent
className="flex-row items-end"
compact
Expand Down
12 changes: 8 additions & 4 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -770,11 +770,11 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
) ||
(props.historyControls?.hasMoreHistory === true &&
props.selectedThread.latestUserMessageAt !== null);
const composerOverlapHeight =
composerHeight ??
const collapsedComposerHeight =
COMPOSER_COLLAPSED_CHROME +
(!isProviderSubagent && (deviceType === "pad" || hasSidebarTabBar) ? 14 : 0) +
composerBottomInset;
(!isProviderSubagent && (deviceType === "pad" || hasSidebarTabBar) ? 14 : 0) +
composerBottomInset;
const composerOverlapHeight = composerHeight ?? collapsedComposerHeight;
// The last collapse toggle; it only applies to the request it was made on.
const [userInputToggle, setUserInputToggle] = useState<{
readonly requestId: RuntimeRequestId;
Expand Down Expand Up @@ -1328,6 +1328,10 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
estimatedOverlayHeight +
(showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
}
placeholderBottomInset={
collapsedComposerHeight +
(showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
}
contentMaxWidth={contentMaxWidth}
historyControls={props.historyControls}
contentSideInsets={nativeMetrics?.safeArea}
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ export interface ThreadFeedProps {
readonly keyboardOffset: number;
readonly contentTopInset?: number;
readonly contentBottomInset?: number;
readonly placeholderBottomInset?: number;
readonly historyControls?: ThreadFeedHistoryControls;
readonly contentMaxWidth?: number;
readonly contentSideInsets?: { readonly left: number; readonly right: number };
Expand Down Expand Up @@ -2244,6 +2245,7 @@ export const ThreadFeed = memo(function ThreadFeed({
const insets = useSafeAreaInsets();
const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT;
const bottomContentInset = props.contentBottomInset ?? 18;
const placeholderBottomInset = props.placeholderBottomInset ?? bottomContentInset;
const usesNativeAutomaticInsets =
props.usesAutomaticContentInsets === true && Platform.OS === "ios";
const initialContentInset = deriveThreadFeedInitialContentInset({
Expand Down Expand Up @@ -3130,7 +3132,7 @@ export const ThreadFeed = memo(function ThreadFeed({
title={props.contentPresentation.title}
detail={props.contentPresentation.detail}
topInset={topContentInset}
bottomInset={bottomContentInset}
bottomInset={placeholderBottomInset}
horizontalPadding={horizontalPadding}
/>
);
Expand Down Expand Up @@ -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}
/>
</View>
Expand Down
Loading
Loading