From 81d3b5e7f2a185c08c548d337be453a3a5085f3a Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 05:53:57 +0000 Subject: [PATCH 01/20] feat(mobile): narrow composers open the card on tap --- .../src/components/ComposerModelControl.tsx | 7 +++- .../src/features/threads/ThreadComposer.tsx | 36 +++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index 431cf61b1ec6..cf1cff08cac0 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -38,6 +38,11 @@ export function ComposerModelControl(props: { : ""; const badge = props.modelOption?.providerBadge; const label = props.modelOption?.label ?? props.selection?.model ?? "Choose model"; + const providerPrefix = `${props.modelOption?.providerLabel} `; + const visibleLabel = + !badge?.showBadge && label.startsWith(providerPrefix) + ? label.slice(providerPrefix.length) + : label; return ( ) } - label={label} + label={visibleLabel} iconOnly={props.iconOnly} suffix={ summary ? ( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 9687136761fe..3f76a0ebce08 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -140,9 +140,16 @@ 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, + opensOnFocus: boolean, + yieldsModelLabel: boolean, +) { const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); const [compactWidth, setCompactWidth] = useState(0); + const [labelYielded, setLabelYielded] = useState(false); + if (labelYielded && value.length === 0) setLabelYielded(false); const contentHeight = contentSize.height; const rowWidth = useSharedValue(0); const editorX = useSharedValue(0); @@ -157,9 +164,16 @@ export function useCompactComposerEditor(text: ScaledTextRole, value: string) { // 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 = + opensOnFocus || + (value.length > 0 && + (contentHeight > text.lineHeight * 1.5 || + (compactWidth > 0 && contentSize.width > compactWidth))); + const nearsModelLabel = + yieldsModelLabel && value.length > 0 && - (contentHeight > text.lineHeight * 1.5 || - (compactWidth > 0 && contentSize.width > compactWidth)); + compactWidth > 0 && + contentSize.width > compactWidth - text.fontSize; + if (nearsModelLabel && !labelYielded) setLabelYielded(true); const compactRowHeight = Math.max(44, minimum); const animatedHeight = useSharedValue(height); const surfaceHeight = useSharedValue(compactRowHeight); @@ -202,6 +216,7 @@ export function useCompactComposerEditor(text: ScaledTextRole, value: string) { })); return { isExpanded, + labelYielded: labelYielded && value.length > 0, containerStyle, editorStyle, onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => { @@ -599,8 +614,14 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const { setComposerEditing } = useAdaptiveWorkspaceLayout(); const hasSidebarTabBar = useSidebarWorkspaceTabs() === "picker"; const [compactRowWidth, setCompactRowWidth] = useState(0); - const compactAttachmentLimit = compactRowWidth >= 500 ? 3 : 1; - const compactEditor = useCompactComposerEditor(bodyText, draftMessage); + const isWideRow = compactRowWidth >= 500; + const compactAttachmentLimit = isWideRow ? 3 : 1; + const compactEditor = useCompactComposerEditor( + bodyText, + draftMessage, + isExpanded && compactRowWidth > 0 && !isWideRow, + isWideRow, + ); const showsCompactDictation = isVoiceInputPresented; const isToolbarVisible = isVoiceInputPresented; const hasEditingAttachments = @@ -855,7 +876,10 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps reportedSelection={props.reportedModelSelection} prompt={draftMessage} surfaceColor={composerPanel} - iconOnly={!compactEditor.isExpanded} + iconOnly={ + !compactEditor.isExpanded && + (!isWideRow || compactEditor.labelYielded || stripAttachments.length > 0) + } onPress={openSettings} /> From 6e814d57eaf3af5b92a292e4d103027d8f1e4921 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 07:26:04 +0000 Subject: [PATCH 02/20] refactor(mobile): share the composer's model display label --- .../src/components/ComposerModelControl.tsx | 8 ++--- apps/mobile/src/lib/modelOptions.test.ts | 36 +++++++++++++++++++ apps/mobile/src/lib/modelOptions.ts | 11 ++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index cf1cff08cac0..9c2c90f0688e 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -3,7 +3,7 @@ import { buildTraitsTriggerDisplay, getPromptControlledTraits, } from "@t3tools/client-runtime/model-traits"; -import type { ModelOption } from "../lib/modelOptions"; +import { modelOptionDisplayLabel, type ModelOption } from "../lib/modelOptions"; import { resolveProviderOptionDescriptors } from "../lib/providerOptions"; import { AppText as Text } from "./ui/AppText"; import { ComposerInlineControl } from "./ComposerToolbar"; @@ -38,11 +38,7 @@ export function ComposerModelControl(props: { : ""; const badge = props.modelOption?.providerBadge; const label = props.modelOption?.label ?? props.selection?.model ?? "Choose model"; - const providerPrefix = `${props.modelOption?.providerLabel} `; - const visibleLabel = - !badge?.showBadge && label.startsWith(providerPrefix) - ? label.slice(providerPrefix.length) - : label; + const visibleLabel = props.modelOption ? modelOptionDisplayLabel(props.modelOption) : label; return ( { ]); }); + it("drops the vendor name only where the provider icon already shows it", () => { + const config = { + providers: [ + { instanceId: "claudeAgent", driver: "claudeAgent" }, + { instanceId: "opencode", driver: "opencode" }, + { instanceId: "codex", driver: "codex" }, + ].map((provider) => ({ + ...provider, + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { slug: "opus", name: "Claude Opus 5.5", capabilities: null }, + { slug: "sol", name: "GPT-6.1 Sol", capabilities: null }, + ], + })), + } as unknown as ServerConfig; + + expect(buildModelOptions(config, null, undefined, null).map(modelOptionDisplayLabel)).toEqual([ + "Opus 5.5", + "GPT-6.1 Sol", + "Claude Opus 5.5", + "GPT-6.1 Sol", + "Claude Opus 5.5", + "GPT-6.1 Sol", + ]); + + const accounts = { + providers: [config.providers[0], { ...config.providers[0], instanceId: "claude_work" }], + } as unknown as ServerConfig; + expect(buildModelOptions(accounts, null, undefined, null).map(modelOptionDisplayLabel)[0]).toBe( + "Claude Opus 5.5", + ); + }); + it("does not materialize catalog defaults for missing stored options", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index c36c63a4f145..603190648803 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -408,6 +408,17 @@ export function buildModelOptions( return [...options.values()]; } +const MODEL_VENDOR_PREFIX_BY_DRIVER: Partial> = { + claudeAgent: "Claude ", +}; + +export function modelOptionDisplayLabel(option: ModelOption): string { + const prefix = MODEL_VENDOR_PREFIX_BY_DRIVER[option.providerDriver]; + return prefix && !option.providerBadge?.showBadge && option.label.startsWith(prefix) + ? option.label.slice(prefix.length) + : option.label; +} + export function groupByProvider(options: ReadonlyArray): ReadonlyArray { const groups = new Map< string, From a0613f3f38c125214fdfc85876a9392638fd6a74 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 07:32:56 +0000 Subject: [PATCH 03/20] fix(mobile): re-measure the composer row after it is resized --- apps/mobile/src/features/threads/ThreadComposer.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 3f76a0ebce08..29799a96f736 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -149,6 +149,7 @@ export function useCompactComposerEditor( const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); const [compactWidth, setCompactWidth] = useState(0); const [labelYielded, setLabelYielded] = useState(false); + const [measuredRowWidth, setMeasuredRowWidth] = useState(0); if (labelYielded && value.length === 0) setLabelYielded(false); const contentHeight = contentSize.height; const rowWidth = useSharedValue(0); @@ -220,7 +221,12 @@ export function useCompactComposerEditor( containerStyle, editorStyle, onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => { - rowWidth.set(event.nativeEvent.layout.width); + const width = event.nativeEvent.layout.width; + rowWidth.set(width); + if (width !== measuredRowWidth) { + setMeasuredRowWidth(width); + setCompactWidth(0); + } }, onEditorLayout: (event: { nativeEvent: { layout: { x: number; width: number } } }) => { // The expanded toolbar gives this slot to the model control. Keep the From 7f7e08a77671ff279199fb6094ca866e852ec469 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 08:54:42 +0000 Subject: [PATCH 04/20] refactor(mobile): leave model label shortening to its own change --- .../src/components/ComposerModelControl.tsx | 5 ++- apps/mobile/src/lib/modelOptions.test.ts | 36 ------------------- apps/mobile/src/lib/modelOptions.ts | 11 ------ 3 files changed, 2 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index 9c2c90f0688e..431cf61b1ec6 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -3,7 +3,7 @@ import { buildTraitsTriggerDisplay, getPromptControlledTraits, } from "@t3tools/client-runtime/model-traits"; -import { modelOptionDisplayLabel, type ModelOption } from "../lib/modelOptions"; +import type { ModelOption } from "../lib/modelOptions"; import { resolveProviderOptionDescriptors } from "../lib/providerOptions"; import { AppText as Text } from "./ui/AppText"; import { ComposerInlineControl } from "./ComposerToolbar"; @@ -38,7 +38,6 @@ export function ComposerModelControl(props: { : ""; const badge = props.modelOption?.providerBadge; const label = props.modelOption?.label ?? props.selection?.model ?? "Choose model"; - const visibleLabel = props.modelOption ? modelOptionDisplayLabel(props.modelOption) : label; return ( ) } - label={visibleLabel} + label={label} iconOnly={props.iconOnly} suffix={ summary ? ( diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index ac1c878f8c5b..11b9b0603be8 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -15,7 +15,6 @@ import { describeUnavailableModelSelection, groupByProvider, isModelSelectionUnavailable, - modelOptionDisplayLabel, resolveDefaultableModelSelection, resolveNewTaskModelSelection, resolveSelectableModelSelection, @@ -72,41 +71,6 @@ describe("mobile model options", () => { ]); }); - it("drops the vendor name only where the provider icon already shows it", () => { - const config = { - providers: [ - { instanceId: "claudeAgent", driver: "claudeAgent" }, - { instanceId: "opencode", driver: "opencode" }, - { instanceId: "codex", driver: "codex" }, - ].map((provider) => ({ - ...provider, - enabled: true, - installed: true, - auth: { status: "authenticated" }, - models: [ - { slug: "opus", name: "Claude Opus 5.5", capabilities: null }, - { slug: "sol", name: "GPT-6.1 Sol", capabilities: null }, - ], - })), - } as unknown as ServerConfig; - - expect(buildModelOptions(config, null, undefined, null).map(modelOptionDisplayLabel)).toEqual([ - "Opus 5.5", - "GPT-6.1 Sol", - "Claude Opus 5.5", - "GPT-6.1 Sol", - "Claude Opus 5.5", - "GPT-6.1 Sol", - ]); - - const accounts = { - providers: [config.providers[0], { ...config.providers[0], instanceId: "claude_work" }], - } as unknown as ServerConfig; - expect(buildModelOptions(accounts, null, undefined, null).map(modelOptionDisplayLabel)[0]).toBe( - "Claude Opus 5.5", - ); - }); - it("does not materialize catalog defaults for missing stored options", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 603190648803..c36c63a4f145 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -408,17 +408,6 @@ export function buildModelOptions( return [...options.values()]; } -const MODEL_VENDOR_PREFIX_BY_DRIVER: Partial> = { - claudeAgent: "Claude ", -}; - -export function modelOptionDisplayLabel(option: ModelOption): string { - const prefix = MODEL_VENDOR_PREFIX_BY_DRIVER[option.providerDriver]; - return prefix && !option.providerBadge?.showBadge && option.label.startsWith(prefix) - ? option.label.slice(prefix.length) - : option.label; -} - export function groupByProvider(options: ReadonlyArray): ReadonlyArray { const groups = new Map< string, From 59008e936ce3ed1626649d9e111d39f7157e6cbf Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 24 Oct 2026 00:08:51 +0000 Subject: [PATCH 05/20] fix(mobile): close the composer card on Android Back and wait for the yielded slot before opening it --- .../src/features/threads/ThreadComposer.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 29799a96f736..866385bd0bfc 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -31,6 +31,7 @@ import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/n import type { ReactNode } from "react"; import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react"; import { Keyboard, Platform, View, type ViewStyle } from "react-native"; +import { useKeyboardState } from "react-native-keyboard-controller"; import { composerImageGallery } from "../../lib/composerImageGallery"; import { filePreviewGallery } from "../../lib/filePreviewGallery"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; @@ -147,10 +148,12 @@ export function useCompactComposerEditor( yieldsModelLabel: boolean, ) { const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); - const [compactWidth, setCompactWidth] = useState(0); + const [compactSlot, setCompactSlot] = useState({ width: 0, labelYielded: false }); const [labelYielded, setLabelYielded] = useState(false); const [measuredRowWidth, setMeasuredRowWidth] = useState(0); if (labelYielded && value.length === 0) setLabelYielded(false); + const compactWidth = + !yieldsModelLabel || compactSlot.labelYielded === labelYielded ? compactSlot.width : 0; const contentHeight = contentSize.height; const rowWidth = useSharedValue(0); const editorX = useSharedValue(0); @@ -225,7 +228,7 @@ export function useCompactComposerEditor( rowWidth.set(width); if (width !== measuredRowWidth) { setMeasuredRowWidth(width); - setCompactWidth(0); + setCompactSlot({ width: 0, labelYielded }); } }, onEditorLayout: (event: { nativeEvent: { layout: { x: number; width: number } } }) => { @@ -234,7 +237,7 @@ export function useCompactComposerEditor( if (!isExpanded) { editorX.set(event.nativeEvent.layout.x); editorWidth.set(event.nativeEvent.layout.width); - setCompactWidth(Math.max(0, event.nativeEvent.layout.width - 8)); + setCompactSlot({ width: Math.max(0, event.nativeEvent.layout.width - 8), labelYielded }); } }, editorProps: { @@ -472,6 +475,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); + const keyboardVisible = useKeyboardState((state) => state.isVisible); const pendingPastedTextAttachmentCountRef = useRef(0); const pendingNativeImagePasteCountRef = useRef(0); const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); @@ -622,10 +626,13 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const [compactRowWidth, setCompactRowWidth] = useState(0); const isWideRow = compactRowWidth >= 500; const compactAttachmentLimit = isWideRow ? 3 : 1; + const opensCard = + (isFocused && (Platform.OS !== "android" || keyboardVisible)) || + settingsSheetPresentation.keepsComposerExpanded; const compactEditor = useCompactComposerEditor( bodyText, draftMessage, - isExpanded && compactRowWidth > 0 && !isWideRow, + opensCard && compactRowWidth > 0 && !isWideRow, isWideRow, ); const showsCompactDictation = isVoiceInputPresented; From 7606cdae8382eb6705467e8dfd25bc6c20e58d6a Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 24 Oct 2026 01:56:39 +0000 Subject: [PATCH 06/20] fix(mobile): pasting a long line no longer flashes the composer card On wide rows, a line that wrapped beside the model label opened the card on its two-line height before the label yielded; the card then measured one line and collapsed. Treat that wrap as a reason to yield, and trust it only once the editor is laid out at the yielded width, which is when the editor reports its re-measured size (or none, when nothing changed). Until then the row stays one line tall. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/ThreadComposer.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 866385bd0bfc..5451f7bf2095 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -151,6 +151,7 @@ export function useCompactComposerEditor( const [compactSlot, setCompactSlot] = useState({ width: 0, labelYielded: false }); const [labelYielded, setLabelYielded] = useState(false); const [measuredRowWidth, setMeasuredRowWidth] = useState(0); + const [editorLayoutWidth, setEditorLayoutWidth] = useState(0); if (labelYielded && value.length === 0) setLabelYielded(false); const compactWidth = !yieldsModelLabel || compactSlot.labelYielded === labelYielded ? compactSlot.width : 0; @@ -161,22 +162,25 @@ export function useCompactComposerEditor( 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 height = Math.max( - minimum, - Math.min(text.lineHeight * 4 + 2 * inset, contentHeight + 2 * inset), - ); + const wraps = contentHeight > text.lineHeight * 1.5; + const wrapIsSettled = + !yieldsModelLabel || + (labelYielded && compactSlot.labelYielded && editorLayoutWidth >= compactSlot.width - 1); // 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 = opensOnFocus || (value.length > 0 && - (contentHeight > text.lineHeight * 1.5 || - (compactWidth > 0 && contentSize.width > compactWidth))); + ((wraps && wrapIsSettled) || (compactWidth > 0 && contentSize.width > compactWidth))); + const visibleHeight = wraps && !isExpanded ? text.lineHeight : contentHeight; + const height = Math.max( + minimum, + Math.min(text.lineHeight * 4 + 2 * inset, visibleHeight + 2 * inset), + ); const nearsModelLabel = yieldsModelLabel && value.length > 0 && - compactWidth > 0 && - contentSize.width > compactWidth - text.fontSize; + (wraps || (compactWidth > 0 && contentSize.width > compactWidth - text.fontSize)); if (nearsModelLabel && !labelYielded) setLabelYielded(true); const compactRowHeight = Math.max(44, minimum); const animatedHeight = useSharedValue(height); @@ -240,6 +244,9 @@ export function useCompactComposerEditor( setCompactSlot({ width: Math.max(0, event.nativeEvent.layout.width - 8), labelYielded }); } }, + onEditorFrameLayout: (event: { nativeEvent: { layout: { width: number } } }) => { + if (event.nativeEvent.layout.width > 0) setEditorLayoutWidth(event.nativeEvent.layout.width); + }, editorProps: { singleLineCentered: !isExpanded, scrollEnabled: Platform.OS === "android" || contentHeight >= text.lineHeight * 4, @@ -1077,6 +1084,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps Date: Sat, 24 Oct 2026 02:20:25 +0000 Subject: [PATCH 07/20] fix(mobile): Android composer card closes with the keyboard --- .../src/features/threads/ThreadComposer.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 5451f7bf2095..187f077c88cb 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -31,7 +31,7 @@ import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/n import type { ReactNode } from "react"; import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react"; import { Keyboard, Platform, View, type ViewStyle } from "react-native"; -import { useKeyboardState } from "react-native-keyboard-controller"; +import { KeyboardController, KeyboardEvents } from "react-native-keyboard-controller"; import { composerImageGallery } from "../../lib/composerImageGallery"; import { filePreviewGallery } from "../../lib/filePreviewGallery"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; @@ -469,6 +469,19 @@ export function ComposerSurface(props: { ); } +function useKeyboardShowing() { + const [showing, setShowing] = useState(KeyboardController.isVisible); + useEffect(() => { + const show = KeyboardEvents.addListener("keyboardWillShow", () => setShowing(true)); + const hide = KeyboardEvents.addListener("keyboardWillHide", () => setShowing(false)); + return () => { + show.remove(); + hide.remove(); + }; + }, []); + return showing; +} + /** Space below the composer pill, inside the bottom inset it is given. */ export const THREAD_COMPOSER_BOTTOM_GUTTER = 6; @@ -482,7 +495,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); - const keyboardVisible = useKeyboardState((state) => state.isVisible); + const keyboardVisible = useKeyboardShowing(); const pendingPastedTextAttachmentCountRef = useRef(0); const pendingNativeImagePasteCountRef = useRef(0); const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); From be67a4022c958bfef6d6e9e065e5109df6db68b0 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 24 Oct 2026 03:17:15 +0000 Subject: [PATCH 08/20] fix(mobile): open the card for wrapped drafts when an attachment already hides the label --- apps/mobile/src/features/threads/ThreadComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 187f077c88cb..1aad2d262285 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -653,7 +653,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps bodyText, draftMessage, opensCard && compactRowWidth > 0 && !isWideRow, - isWideRow, + isWideRow && stripAttachments.length === 0, ); const showsCompactDictation = isVoiceInputPresented; const isToolbarVisible = isVoiceInputPresented; From 00c7311b9b4876ed124ec32fb29473acbc9aba0a Mon Sep 17 00:00:00 2001 From: flamboh Date: Sun, 25 Oct 2026 20:55:03 +0000 Subject: [PATCH 09/20] fix(mobile): keep the empty-thread message still when the composer card opens --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 6 ++++-- apps/mobile/src/features/threads/ThreadFeed.tsx | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 49294b4e1cba..07bf7fc8c8fe 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -769,8 +769,9 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { ) || (props.historyControls?.hasMoreHistory === true && props.selectedThread.latestUserMessageAt !== null); - const composerOverlapHeight = - composerHeight ?? COMPOSER_COLLAPSED_CHROME + (hasSidebarTabBar ? 14 : 0) + composerBottomInset; + const collapsedComposerHeight = + COMPOSER_COLLAPSED_CHROME + (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; @@ -1317,6 +1318,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { estimatedOverlayHeight + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0) } + placeholderBottomInset={collapsedComposerHeight} contentMaxWidth={contentMaxWidth} historyControls={props.historyControls} contentSideInsets={nativeMetrics?.safeArea} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index e743e6ff23ec..b2ef41eeda11 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -351,6 +351,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 }; @@ -2243,6 +2244,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({ @@ -3128,7 +3130,7 @@ export const ThreadFeed = memo(function ThreadFeed({ title={props.contentPresentation.title} detail={props.contentPresentation.detail} topInset={topContentInset} - bottomInset={bottomContentInset} + bottomInset={placeholderBottomInset} horizontalPadding={horizontalPadding} /> ); @@ -3278,7 +3280,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} /> From 0d64a2209220726f1458193162932aab3fd275a7 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sun, 25 Oct 2026 21:05:13 +0000 Subject: [PATCH 10/20] fix(mobile): leave room for the floating status control under the empty-thread message --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 07bf7fc8c8fe..9b580c74f206 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1318,7 +1318,10 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { estimatedOverlayHeight + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0) } - placeholderBottomInset={collapsedComposerHeight} + placeholderBottomInset={ + collapsedComposerHeight + + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0) + } contentMaxWidth={contentMaxWidth} historyControls={props.historyControls} contentSideInsets={nativeMetrics?.safeArea} From d22a6d482beb7ff2f342e0a1beaddbad1c3a537f Mon Sep 17 00:00:00 2001 From: flamboh Date: Mon, 26 Oct 2026 01:15:46 +0000 Subject: [PATCH 11/20] chore(mobile): justify the keyboard listener effect for the effect budget --- apps/mobile/src/features/threads/ThreadComposer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 1aad2d262285..62b6a13755d5 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -471,6 +471,7 @@ export function ComposerSurface(props: { function useKeyboardShowing() { const [showing, setShowing] = useState(KeyboardController.isVisible); + // oxlint-disable-next-line t3code/react-effect-budget -- subscribes to native keyboard show/hide events useEffect(() => { const show = KeyboardEvents.addListener("keyboardWillShow", () => setShowing(true)); const hide = KeyboardEvents.addListener("keyboardWillHide", () => setShowing(false)); From 38903140db4cd70276c5f785d9496d5bac887221 Mon Sep 17 00:00:00 2001 From: flamboh Date: Mon, 26 Oct 2026 01:53:41 +0000 Subject: [PATCH 12/20] feat(mobile): the model name slides out of the icon and the draft slides back as the composer card opens and closes --- .../mobile/src/components/ComposerToolbar.tsx | 164 ++++++++++++++---- .../src/features/threads/ThreadComposer.tsx | 10 +- 2 files changed, 136 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index f2b92eaf8926..cc10970144ff 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -1,6 +1,7 @@ import type { ComponentProps, ReactNode } from "react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useState } from "react"; import { + Platform, Pressable, ScrollView, View, @@ -12,7 +13,14 @@ import { type ViewStyle, } from "react-native"; -import Animated, { FadeIn } from "react-native-reanimated"; +import Animated, { + FadeIn, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, + type SharedValue, +} from "react-native-reanimated"; import { cn } from "../lib/cn"; import { AppText as Text } from "./ui/AppText"; @@ -23,6 +31,20 @@ const COMPOSER_TOOLBAR_GAP = 8; const COMPOSER_TOOLBAR_FADE_WIDTH = 18; const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; const INLINE_CONTROL_LABEL_ENTERING = FadeIn.duration(160); +const INLINE_CONTROL_LABEL_DURATION = 180; +type LabelPhase = "shown" | "hiding" | "hidden" | "showing"; + +function slideLabel( + reveal: SharedValue, + target: 0 | 1, + setPhase: (phase: LabelPhase) => void, +) { + reveal.set( + withTiming(target, { duration: INLINE_CONTROL_LABEL_DURATION }, (finished) => { + if (finished) runOnJS(setPhase)(target === 1 ? "shown" : "hidden"); + }), + ); +} export function ComposerInlineControl(props: { readonly size?: "regular" | "large"; readonly accessibilityHint?: string; @@ -45,12 +67,75 @@ export function ComposerInlineControl(props: { const { scale, smallIconSize } = useAndroidControlSizing(); // Keep the picker anchored to its parent while the editor changes height. const animatesLabel = props.iconOnly !== undefined; + const revealsLabel = animatesLabel && Platform.OS !== "android"; const iconSize = props.size === "large" ? 20 : smallIconSize; + const chevronSize = Math.round((props.size === "large" ? 12 : 10) * scale); + const labelShown = !props.iconOnly; + const [phase, setPhase] = useState(labelShown ? "shown" : "hidden"); + const [labelWidth, setLabelWidth] = useState(0); + if (labelShown && (phase === "hidden" || phase === "hiding")) { + setPhase(revealsLabel ? "showing" : "shown"); + } + if (!labelShown && (phase === "shown" || phase === "showing")) { + setPhase(revealsLabel && labelWidth > 0 ? "hiding" : "hidden"); + } + const sliding = phase === "hiding" || phase === "showing"; + const reveal = useSharedValue(labelShown ? 1 : 0); + const revealWidth = useSharedValue(0); + // oxlint-disable-next-line t3code/react-effect-budget -- starts the UI-thread slide when the label phase changes + useLayoutEffect(() => { + if (phase === "hiding") slideLabel(reveal, 0, setPhase); + else if (phase === "showing" && revealWidth.get() > 0) slideLabel(reveal, 1, setPhase); + else if (phase === "shown") reveal.set(1); + else if (phase === "hidden") reveal.set(0); + }, [phase, reveal, revealWidth]); + const [iconFrame, setIconFrame] = useState({ x: 8, width: iconSize }); + const [labelX, setLabelX] = useState(16 + iconSize); + const labelGap = labelX - iconFrame.x - iconFrame.width; + const overlayStyle = useAnimatedStyle(() => { + const width = iconFrame.width + labelGap + reveal.value * revealWidth.value; + const rightEdge = + phase === "showing" ? labelX + revealWidth.value : iconFrame.x + iconFrame.width; + return { left: rightEdge - width, width }; + }); + const overlayLabelStyle = useAnimatedStyle(() => ({ + width: reveal.value * revealWidth.value, + })); + const recordLabelWidth = (width: number) => { + revealWidth.set(width); + if (width !== labelWidth) setLabelWidth(width); + }; + const tint = props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"; + const iconNode = props.renderIcon ? ( + + {props.renderIcon(iconSize)} + + ) : props.icon ? ( + + ) : null; + const labelContent = ( + <> + + {props.label} + + {props.suffix ? {props.suffix} : null} + + ); return ( - {props.renderIcon ? ( - { + const { x, width } = event.nativeEvent.layout; + if (x !== iconFrame.x || width !== iconFrame.width) setIconFrame({ x, width }); + }} + > + {iconNode} + + {phase === "shown" || phase === "showing" ? ( + { + const { x, width } = event.nativeEvent.layout; + if (x !== labelX) setLabelX(x); + const firstMeasure = phase === "showing" && revealWidth.get() === 0; + recordLabelWidth(width); + if (firstMeasure) slideLabel(reveal, 1, setPhase); + }} > - {props.renderIcon(iconSize)} - - ) : props.icon ? ( - + {labelContent} + ) : null} - {props.iconOnly ? null : ( + {sliding ? ( - {iconNode} + - {props.label} - + + {labelContent} + + - )} - {!props.iconOnly && props.suffix ? ( - {props.suffix} ) : null} {props.showChevron === false ? null : ( )} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 62b6a13755d5..e931e21d3d8e 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -217,8 +217,14 @@ export function useCompactComposerEditor( position: "absolute", overflow: "hidden", height: animatedHeight.value, - left: isExpanded ? 14 : editorX.value + 4, - right: isExpanded ? 14 : rowWidth.value - editorX.value - editorWidth.value + 4, + left: isExpanded ? 14 : editorX.value + 4 + expansion.value * (10 - editorX.value), + right: isExpanded + ? 14 + : rowWidth.value - + editorX.value - + editorWidth.value + + 4 + + expansion.value * (10 - rowWidth.value + editorX.value + editorWidth.value), bottom: (compactRowHeight - minimum) / 2 + expansion.value * (52 - (compactRowHeight - minimum) / 2), })); From 737bfb1e72eb9acd6b18dca25b690e51e81f0cfc Mon Sep 17 00:00:00 2001 From: flamboh Date: Mon, 26 Oct 2026 03:47:19 +0000 Subject: [PATCH 13/20] fix(mobile): wide composers keep the model label and open the card as the draft reaches it The label no longer shrinks to the icon while typing on wide rows; the card opens about a character before the draft would wrap. Card transitions slide the draft and model name instead of jumping, and the height animation no longer restarts mid-open. --- .../mobile/src/components/ComposerToolbar.tsx | 80 ++-- .../src/features/threads/ThreadComposer.tsx | 348 +++++++++--------- 2 files changed, 230 insertions(+), 198 deletions(-) diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index cc10970144ff..c0df5f8464c4 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -1,6 +1,7 @@ import type { ComponentProps, ReactNode } from "react"; import { useCallback, useLayoutEffect, useMemo, useState } from "react"; import { + PixelRatio, Platform, Pressable, ScrollView, @@ -18,6 +19,7 @@ import Animated, { runOnJS, useAnimatedStyle, useSharedValue, + withDelay, withTiming, type SharedValue, } from "react-native-reanimated"; @@ -31,7 +33,6 @@ const COMPOSER_TOOLBAR_GAP = 8; const COMPOSER_TOOLBAR_FADE_WIDTH = 18; const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; const INLINE_CONTROL_LABEL_ENTERING = FadeIn.duration(160); -const INLINE_CONTROL_LABEL_DURATION = 180; type LabelPhase = "shown" | "hiding" | "hidden" | "showing"; function slideLabel( @@ -39,11 +40,10 @@ function slideLabel( target: 0 | 1, setPhase: (phase: LabelPhase) => void, ) { - reveal.set( - withTiming(target, { duration: INLINE_CONTROL_LABEL_DURATION }, (finished) => { - if (finished) runOnJS(setPhase)(target === 1 ? "shown" : "hidden"); - }), - ); + const slide = withTiming(target, { duration: target === 1 ? 180 : 120 }, (finished) => { + if (finished) runOnJS(setPhase)(target === 1 ? "shown" : "hidden"); + }); + reveal.set(target === 1 ? withDelay(100, slide) : slide); } export function ComposerInlineControl(props: { readonly size?: "regular" | "large"; @@ -89,17 +89,19 @@ export function ComposerInlineControl(props: { else if (phase === "shown") reveal.set(1); else if (phase === "hidden") reveal.set(0); }, [phase, reveal, revealWidth]); - const [iconFrame, setIconFrame] = useState({ x: 8, width: iconSize }); + const [iconFrame, setIconFrame] = useState({ x: 8, y: 12, width: iconSize, height: iconSize }); + const [labelBox, setLabelBox] = useState({ y: 0, height: 44 }); + const pixelScale = PixelRatio.get(); const [labelX, setLabelX] = useState(16 + iconSize); const labelGap = labelX - iconFrame.x - iconFrame.width; - const overlayStyle = useAnimatedStyle(() => { - const width = iconFrame.width + labelGap + reveal.value * revealWidth.value; - const rightEdge = - phase === "showing" ? labelX + revealWidth.value : iconFrame.x + iconFrame.width; - return { left: rightEdge - width, width }; - }); + const [chevronInset, setChevronInset] = useState(16 + chevronSize); + const overlayStyle = useAnimatedStyle(() => ({ + width: + Math.round((iconFrame.width + reveal.value * (labelGap + revealWidth.value)) * pixelScale) / + pixelScale, + })); const overlayLabelStyle = useAnimatedStyle(() => ({ - width: reveal.value * revealWidth.value, + width: Math.round(reveal.value * revealWidth.value * pixelScale) / pixelScale, })); const recordLabelWidth = (width: number) => { revealWidth.set(width); @@ -153,8 +155,15 @@ export function ComposerInlineControl(props: { { - const { x, width } = event.nativeEvent.layout; - if (x !== iconFrame.x || width !== iconFrame.width) setIconFrame({ x, width }); + const { x, y, width, height } = event.nativeEvent.layout; + if ( + x !== iconFrame.x || + y !== iconFrame.y || + width !== iconFrame.width || + height !== iconFrame.height + ) { + setIconFrame({ x, y, width, height }); + } }} > {iconNode} @@ -165,8 +174,9 @@ export function ComposerInlineControl(props: { entering={animatesLabel && !revealsLabel ? INLINE_CONTROL_LABEL_ENTERING : undefined} style={{ opacity: sliding ? 0 : 1 }} onLayout={(event) => { - const { x, width } = event.nativeEvent.layout; + const { x, y, width, height } = event.nativeEvent.layout; if (x !== labelX) setLabelX(x); + if (y !== labelBox.y || height !== labelBox.height) setLabelBox({ y, height }); const firstMeasure = phase === "showing" && revealWidth.get() === 0; recordLabelWidth(width); if (firstMeasure) slideLabel(reveal, 1, setPhase); @@ -179,16 +189,24 @@ export function ComposerInlineControl(props: { - {iconNode} + + {iconNode} + {labelContent} @@ -196,12 +214,20 @@ export function ComposerInlineControl(props: { ) : null} {props.showChevron === false ? null : ( - + { + const { x, width } = event.nativeEvent.layout; + const inset = labelGap + width + iconFrame.x; + if (Math.abs(inset - chevronInset) > 0.5 && x > 0) setChevronInset(inset); + }} + > + + )} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e931e21d3d8e..c1a4e6bebe56 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -145,16 +145,10 @@ export function useCompactComposerEditor( text: ScaledTextRole, value: string, opensOnFocus: boolean, - yieldsModelLabel: boolean, ) { const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); - const [compactSlot, setCompactSlot] = useState({ width: 0, labelYielded: false }); - const [labelYielded, setLabelYielded] = useState(false); + const [compactWidth, setCompactWidth] = useState(0); const [measuredRowWidth, setMeasuredRowWidth] = useState(0); - const [editorLayoutWidth, setEditorLayoutWidth] = useState(0); - if (labelYielded && value.length === 0) setLabelYielded(false); - const compactWidth = - !yieldsModelLabel || compactSlot.labelYielded === labelYielded ? compactSlot.width : 0; const contentHeight = contentSize.height; const rowWidth = useSharedValue(0); const editorX = useSharedValue(0); @@ -163,30 +157,23 @@ export function useCompactComposerEditor( Platform.OS === "android" ? COLLAPSED_EDITOR_DEFAULT_INSET : collapsedEditorInset(text); const minimum = Math.max(INLINE_EDITOR_HEIGHT, Math.ceil(text.lineHeight) + 2 * inset); const wraps = contentHeight > text.lineHeight * 1.5; - const wrapIsSettled = - !yieldsModelLabel || - (labelYielded && compactSlot.labelYielded && editorLayoutWidth >= compactSlot.width - 1); // 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 = opensOnFocus || (value.length > 0 && - ((wraps && wrapIsSettled) || (compactWidth > 0 && contentSize.width > compactWidth))); + (wraps || (compactWidth > 0 && contentSize.width > compactWidth - text.fontSize))); const visibleHeight = wraps && !isExpanded ? text.lineHeight : contentHeight; const height = Math.max( minimum, Math.min(text.lineHeight * 4 + 2 * inset, visibleHeight + 2 * inset), ); - const nearsModelLabel = - yieldsModelLabel && - value.length > 0 && - (wraps || (compactWidth > 0 && contentSize.width > compactWidth - text.fontSize)); - if (nearsModelLabel && !labelYielded) setLabelYielded(true); const compactRowHeight = Math.max(44, minimum); const animatedHeight = useSharedValue(height); const surfaceHeight = useSharedValue(compactRowHeight); const targetSurfaceHeight = isExpanded ? height + 52 : compactRowHeight; const expansion = useSharedValue(0); + const expansionTarget = useRef({ expanded: isExpanded, pending: false }); useLayoutEffect(() => { const config = { duration: 180, reduceMotion: ReduceMotion.System }; if (Platform.OS === "android") { @@ -195,11 +182,20 @@ export function useCompactComposerEditor( expansion.set(Number(isExpanded)); return; } + const target = expansionTarget.current; + if (target.expanded === isExpanded && !target.pending) { + animatedHeight.set(withTiming(height, config)); + surfaceHeight.set(withTiming(targetSurfaceHeight, config)); + return; + } + target.expanded = isExpanded; + target.pending = true; // Resolve wrapping at the destination width before animating vertically. // Intermediate widths otherwise produce taller measurements mid-transition. let nextFrame: number | undefined; const frame = requestAnimationFrame(() => { nextFrame = requestAnimationFrame(() => { + target.pending = false; animatedHeight.set(withTiming(height, config)); surfaceHeight.set(withTiming(targetSurfaceHeight, config)); expansion.set(withTiming(Number(isExpanded), config)); @@ -213,46 +209,57 @@ export function useCompactComposerEditor( const containerStyle = useAnimatedStyle(() => ({ height: surfaceHeight.value, })); - const editorStyle = useAnimatedStyle(() => ({ + const editorStyle = useAnimatedStyle(() => { + const rowLeft = editorX.value + 4; + const rowRight = rowWidth.value - editorX.value - editorWidth.value + 4; + return { + position: "absolute", + overflow: "hidden", + height: animatedHeight.value, + left: rowLeft + expansion.value * (14 - rowLeft), + right: rowRight + expansion.value * (14 - rowRight), + bottom: + (compactRowHeight - minimum) / 2 + + expansion.value * (52 - (compactRowHeight - minimum) / 2), + }; + }); + const editorContentStyle = useAnimatedStyle(() => ({ position: "absolute", - overflow: "hidden", - height: animatedHeight.value, - left: isExpanded ? 14 : editorX.value + 4 + expansion.value * (10 - editorX.value), - right: isExpanded - ? 14 - : rowWidth.value - - editorX.value - - editorWidth.value + - 4 + - expansion.value * (10 - rowWidth.value + editorX.value + editorWidth.value), - bottom: - (compactRowHeight - minimum) / 2 + expansion.value * (52 - (compactRowHeight - minimum) / 2), + top: 0, + bottom: 0, + left: 0, + width: Math.max(0, isExpanded ? rowWidth.value - 28 : editorWidth.value - 8), + transform: [ + { + translateY: isExpanded + ? (1 - expansion.value) * ((minimum - text.lineHeight) / 2 - inset) + : 0, + }, + ], })); return { isExpanded, - labelYielded: labelYielded && value.length > 0, containerStyle, editorStyle, + editorContentStyle, onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => { const width = event.nativeEvent.layout.width; rowWidth.set(width); if (width !== measuredRowWidth) { setMeasuredRowWidth(width); - setCompactSlot({ width: 0, labelYielded }); + setCompactWidth(0); } }, 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); - setCompactSlot({ width: Math.max(0, event.nativeEvent.layout.width - 8), labelYielded }); + const { x, width } = event.nativeEvent.layout; + editorX.set(x); + editorWidth.set(width); + setCompactWidth(Math.max(0, width - 8)); } }, - onEditorFrameLayout: (event: { nativeEvent: { layout: { width: number } } }) => { - if (event.nativeEvent.layout.width > 0) setEditorLayoutWidth(event.nativeEvent.layout.width); - }, editorProps: { singleLineCentered: !isExpanded, scrollEnabled: Platform.OS === "android" || contentHeight >= text.lineHeight * 4, @@ -660,7 +667,6 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps bodyText, draftMessage, opensCard && compactRowWidth > 0 && !isWideRow, - isWideRow && stripAttachments.length === 0, ); const showsCompactDictation = isVoiceInputPresented; const isToolbarVisible = isVoiceInputPresented; @@ -916,10 +922,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps reportedSelection={props.reportedModelSelection} prompt={draftMessage} surfaceColor={composerPanel} - iconOnly={ - !compactEditor.isExpanded && - (!isWideRow || compactEditor.labelYielded || stripAttachments.length > 0) - } + iconOnly={!compactEditor.isExpanded && (!isWideRow || stripAttachments.length > 0)} onPress={openSettings} /> @@ -1104,146 +1107,149 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps - Date: Tue, 27 Oct 2026 07:54:02 +0000 Subject: [PATCH 14/20] test(mobile): takeover tests for the compact composer card Takeover tests for #23986. Moves the composer's card and model-label decisions out of useCompactComposerEditor and ThreadComposer into compactComposerLayout.ts without changing behaviour, and covers the narrow-row focus card, the wide-row resting label, the early overflow threshold, and the compact-slot re-measure when the row width changes. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/ThreadComposer.tsx | 49 ++++++--- .../threads/compactComposerLayout.test.ts | 104 ++++++++++++++++++ .../features/threads/compactComposerLayout.ts | 53 +++++++++ 3 files changed, 189 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/features/threads/compactComposerLayout.test.ts create mode 100644 apps/mobile/src/features/threads/compactComposerLayout.ts diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index fea40ea42a1e..f6866859a67c 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -106,6 +106,13 @@ import { ComposerDictationToolbar, } from "../voice-input/ComposerDictationControl"; import { useVoiceInputController } from "../voice-input/useVoiceInputController"; +import { + compactSlotAfterRowLayout, + composerModelIconOnly, + composerOpensCardOnFocus, + isCompactComposerExpanded, + isWideComposerRow, +} from "./compactComposerLayout"; import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation"; import type { ExistingThreadSettingsRouteParams } from "./ThreadSettingsSheet"; import { @@ -144,13 +151,14 @@ export function useCompactComposerEditor( 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 wraps = contentHeight > text.lineHeight * 1.5; - // 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 = - opensOnFocus || - (value.length > 0 && - (wraps || (compactWidth > 0 && contentSize.width > compactWidth - text.fontSize))); + const isExpanded = isCompactComposerExpanded({ + opensOnFocus, + value, + contentSize, + compactWidth, + lineHeight: text.lineHeight, + fontSize: text.fontSize, + }); const height = Math.max( minimum, Math.min(text.lineHeight * 4 + 2 * inset, contentHeight + 2 * inset), @@ -172,9 +180,10 @@ export function useCompactComposerEditor( onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => { const width = event.nativeEvent.layout.width; rowWidth.set(width); - if (width !== measuredRowWidth) { - setMeasuredRowWidth(width); - setCompactWidth(0); + const slot = compactSlotAfterRowLayout({ rowWidth: measuredRowWidth, compactWidth }, width); + if (slot.rowWidth !== measuredRowWidth) { + setMeasuredRowWidth(slot.rowWidth); + setCompactWidth(slot.compactWidth); } }, onEditorLayout: (event: { nativeEvent: { layout: { x: number; width: number } } }) => { @@ -592,15 +601,17 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const { setComposerEditing } = useAdaptiveWorkspaceLayout(); const hasSidebarTabBar = useSidebarWorkspaceTabs() === "picker"; const [compactRowWidth, setCompactRowWidth] = useState(0); - const isWideRow = compactRowWidth >= 500; - const compactAttachmentLimit = isWideRow ? 3 : 1; - const opensCard = - (isFocused && (Platform.OS !== "android" || keyboardVisible)) || - settingsSheetPresentation.keepsComposerExpanded; + const compactAttachmentLimit = isWideComposerRow(compactRowWidth) ? 3 : 1; const compactEditor = useCompactComposerEditor( bodyText, draftMessage, - opensCard && compactRowWidth > 0 && !isWideRow, + composerOpensCardOnFocus({ + rowWidth: compactRowWidth, + isFocused, + isAndroid: Platform.OS === "android", + keyboardVisible, + keepsSettingsOpen: settingsSheetPresentation.keepsComposerExpanded, + }), ); const showsCompactDictation = isVoiceInputPresented; const isToolbarVisible = isVoiceInputPresented; @@ -811,7 +822,11 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps reportedSelection={props.reportedModelSelection} prompt={draftMessage} surfaceColor={composerPanel} - iconOnly={!compactEditor.isExpanded && (!isWideRow || stripAttachments.length > 0)} + iconOnly={composerModelIconOnly({ + isExpanded: compactEditor.isExpanded, + rowWidth: compactRowWidth, + attachmentCount: stripAttachments.length, + })} onPress={openSettings} /> 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..f4362fe7b5dc --- /dev/null +++ b/apps/mobile/src/features/threads/compactComposerLayout.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + compactSlotAfterRowLayout, + composerModelIconOnly, + composerOpensCardOnFocus, + isCompactComposerExpanded, +} 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 isAndroid?: boolean; + readonly keyboardVisible?: boolean; + readonly keepsSettingsOpen?: boolean; + readonly value?: string; + readonly contentSize?: { readonly width: number; readonly height: number }; + readonly compactWidth?: number; + readonly attachmentCount?: number; + } = {}, +) { + const rowWidth = input.rowWidth ?? NARROW; + const isExpanded = isCompactComposerExpanded({ + opensOnFocus: composerOpensCardOnFocus({ + rowWidth, + isFocused: input.isFocused ?? false, + isAndroid: input.isAndroid ?? false, + keyboardVisible: input.keyboardVisible ?? false, + keepsSettingsOpen: input.keepsSettingsOpen ?? false, + }), + value: input.value ?? "", + contentSize: input.contentSize ?? { width: 0, height: LINE_HEIGHT }, + compactWidth: input.compactWidth ?? 200, + lineHeight: LINE_HEIGHT, + fontSize: FONT_SIZE, + }); + return { + isExpanded, + modelIconOnly: composerModelIconOnly({ + isExpanded, + rowWidth, + attachmentCount: input.attachmentCount ?? 0, + }), + }; +} + +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 }); + expect(composer({ isFocused: true, isAndroid: true }).isExpanded).toBe(false); + expect(composer({ isFocused: true, isAndroid: true, keyboardVisible: true }).isExpanded).toBe( + true, + ); + 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 = { 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, + }); + }); +}); diff --git a/apps/mobile/src/features/threads/compactComposerLayout.ts b/apps/mobile/src/features/threads/compactComposerLayout.ts new file mode 100644 index 000000000000..42192f9e326d --- /dev/null +++ b/apps/mobile/src/features/threads/compactComposerLayout.ts @@ -0,0 +1,53 @@ +export const WIDE_COMPOSER_ROW_WIDTH = 500; + +export function isWideComposerRow(rowWidth: number): boolean { + return rowWidth >= WIDE_COMPOSER_ROW_WIDTH; +} + +/** Whether focus or the settings sheet opens the card, before the draft is considered. */ +export function composerOpensCardOnFocus(input: { + readonly rowWidth: number; + readonly isFocused: boolean; + readonly isAndroid: boolean; + readonly keyboardVisible: boolean; + readonly keepsSettingsOpen: boolean; +}): boolean { + const opensCard = + (input.isFocused && (!input.isAndroid || input.keyboardVisible)) || input.keepsSettingsOpen; + return opensCard && input.rowWidth > 0 && !isWideComposerRow(input.rowWidth); +} + +export function isCompactComposerExpanded(input: { + readonly opensOnFocus: boolean; + readonly value: string; + readonly contentSize: { readonly width: number; readonly height: number }; + readonly compactWidth: number; + readonly lineHeight: number; + readonly fontSize: number; +}): boolean { + 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 so widening cannot trigger a collapse loop. + return ( + input.opensOnFocus || + (input.value.length > 0 && + (wraps || + (input.compactWidth > 0 && input.contentSize.width > input.compactWidth - input.fontSize))) + ); +} + +/** A new row width forgets the compact slot so the next collapsed layout re-measures it. */ +export function compactSlotAfterRowLayout( + slot: { readonly rowWidth: number; readonly compactWidth: number }, + rowWidth: number, +): { readonly rowWidth: number; readonly compactWidth: number } { + return rowWidth === slot.rowWidth ? slot : { rowWidth, compactWidth: 0 }; +} + +export function composerModelIconOnly(input: { + readonly isExpanded: boolean; + readonly rowWidth: number; + readonly attachmentCount: number; +}): boolean { + return !input.isExpanded && (!isWideComposerRow(input.rowWidth) || input.attachmentCount > 0); +} From 4a5b132fec3e6682e315d698a954b2c78f4a7c96 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 07:59:14 +0000 Subject: [PATCH 15/20] refactor(mobile): compact composer resolves its layout in one place The editor hook owns the row width and compact slot in one state and calls a single resolveCompactComposerLayout for the card and the model label. The Android keyboard gate reuses the terminal's will-event keyboard store, now shared as useKeyboardShowing, instead of a new effect, and iOS never subscribes. The label hides only for thumbnails actually in the row. Co-Authored-By: Claude Opus 5.5 --- .../terminal/ThreadTerminalRouteScreen.tsx | 26 +----- .../src/features/threads/ThreadComposer.tsx | 87 +++++++------------ .../threads/compactComposerLayout.test.ts | 32 ++----- .../features/threads/compactComposerLayout.ts | 69 +++++++-------- apps/mobile/src/lib/keyboardShowing.ts | 26 ++++++ 5 files changed, 102 insertions(+), 138 deletions(-) create mode 100644 apps/mobile/src/lib/keyboardShowing.ts 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 f6866859a67c..0ef6e13f9f1f 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -30,9 +30,9 @@ import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/n import type { ReactNode } from "react"; import { useEffect, useEffectEvent, useRef, useState, type RefObject } from "react"; import { Keyboard, Platform, View, type ViewStyle } from "react-native"; -import { KeyboardController, KeyboardEvents } from "react-native-keyboard-controller"; import { composerImageGallery } from "../../lib/composerImageGallery"; import { filePreviewGallery } from "../../lib/filePreviewGallery"; +import { useKeyboardShowing } from "../../lib/keyboardShowing"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { composerAttachmentUploadBlockReason, @@ -108,10 +108,8 @@ import { import { useVoiceInputController } from "../voice-input/useVoiceInputController"; import { compactSlotAfterRowLayout, - composerModelIconOnly, - composerOpensCardOnFocus, - isCompactComposerExpanded, isWideComposerRow, + resolveCompactComposerLayout, } from "./compactComposerLayout"; import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation"; import type { ExistingThreadSettingsRouteParams } from "./ThreadSettingsSheet"; @@ -122,6 +120,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. @@ -139,11 +140,15 @@ function collapsedEditorInset(text: ScaledTextRole): number { export function useCompactComposerEditor( text: ScaledTextRole, value: string, - opensOnFocus: boolean, + state: { + readonly isFocused: boolean; + readonly keepsSettingsOpen: boolean; + readonly rowAttachmentCount: number; + }, ) { const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); - const [compactWidth, setCompactWidth] = useState(0); - const [measuredRowWidth, setMeasuredRowWidth] = useState(0); + const [slot, setSlot] = useState({ rowWidth: 0, compactWidth: 0 }); + const keyboardShowing = useAndroidKeyboardShowing(); const contentHeight = contentSize.height; const rowWidth = useSharedValue(0); const editorX = useSharedValue(0); @@ -151,11 +156,13 @@ export function useCompactComposerEditor( 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 = isCompactComposerExpanded({ - opensOnFocus, + const { isExpanded, modelIconOnly } = resolveCompactComposerLayout({ + ...state, + slot, + isAndroid: Platform.OS === "android", + keyboardShowing, value, contentSize, - compactWidth, lineHeight: text.lineHeight, fontSize: text.fontSize, }); @@ -175,16 +182,14 @@ export function useCompactComposerEditor( })); return { isExpanded, + modelIconOnly, + rowWidth: slot.rowWidth, containerStyle: { height: isExpanded ? height + 52 : compactRowHeight }, editorStyle, onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => { const width = event.nativeEvent.layout.width; rowWidth.set(width); - const slot = compactSlotAfterRowLayout({ rowWidth: measuredRowWidth, compactWidth }, width); - if (slot.rowWidth !== measuredRowWidth) { - setMeasuredRowWidth(slot.rowWidth); - setCompactWidth(slot.compactWidth); - } + 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 @@ -193,7 +198,10 @@ export function useCompactComposerEditor( const { x, width } = event.nativeEvent.layout; editorX.set(x); editorWidth.set(width); - setCompactWidth(Math.max(0, width - 8)); + const compactWidth = Math.max(0, width - 8); + setSlot((current) => + current.compactWidth === compactWidth ? current : { ...current, compactWidth }, + ); } }, editorProps: { @@ -413,20 +421,6 @@ export function ComposerSurface(props: { ); } -function useKeyboardShowing() { - const [showing, setShowing] = useState(KeyboardController.isVisible); - // oxlint-disable-next-line t3code/react-effect-budget -- subscribes to native keyboard show/hide events - useEffect(() => { - const show = KeyboardEvents.addListener("keyboardWillShow", () => setShowing(true)); - const hide = KeyboardEvents.addListener("keyboardWillHide", () => setShowing(false)); - return () => { - show.remove(); - hide.remove(); - }; - }, []); - return showing; -} - /** Space below the composer pill, inside the bottom inset it is given. */ export const THREAD_COMPOSER_BOTTOM_GUTTER = 6; @@ -440,7 +434,6 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); - const keyboardVisible = useKeyboardShowing(); const pendingPastedTextAttachmentCountRef = useRef(0); const pendingNativeImagePasteCountRef = useRef(0); const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); @@ -600,19 +593,12 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; const { setComposerEditing } = useAdaptiveWorkspaceLayout(); const hasSidebarTabBar = useSidebarWorkspaceTabs() === "picker"; - const [compactRowWidth, setCompactRowWidth] = useState(0); - const compactAttachmentLimit = isWideComposerRow(compactRowWidth) ? 3 : 1; - const compactEditor = useCompactComposerEditor( - bodyText, - draftMessage, - composerOpensCardOnFocus({ - rowWidth: compactRowWidth, - isFocused, - isAndroid: Platform.OS === "android", - keyboardVisible, - keepsSettingsOpen: settingsSheetPresentation.keepsComposerExpanded, - }), - ); + const compactEditor = useCompactComposerEditor(bodyText, draftMessage, { + isFocused, + keepsSettingsOpen: settingsSheetPresentation.keepsComposerExpanded, + rowAttachmentCount: isExpanded ? 0 : stripAttachments.length, + }); + const compactAttachmentLimit = isWideComposerRow(compactEditor.rowWidth) ? 3 : 1; const showsCompactDictation = isVoiceInputPresented; const isToolbarVisible = isVoiceInputPresented; const hasEditingAttachments = @@ -822,11 +808,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps reportedSelection={props.reportedModelSelection} prompt={draftMessage} surfaceColor={composerPanel} - iconOnly={composerModelIconOnly({ - isExpanded: compactEditor.isExpanded, - rowWidth: compactRowWidth, - attachmentCount: stripAttachments.length, - })} + iconOnly={compactEditor.modelIconOnly} onPress={openSettings} /> @@ -910,12 +892,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps }} > {isExpanded ? editingAttachments : null} - { - compactEditor.onRowLayout(event); - setCompactRowWidth(event.nativeEvent.layout.width); - }} - > + { diff --git a/apps/mobile/src/features/threads/compactComposerLayout.ts b/apps/mobile/src/features/threads/compactComposerLayout.ts index 42192f9e326d..e9452ced8ba0 100644 --- a/apps/mobile/src/features/threads/compactComposerLayout.ts +++ b/apps/mobile/src/features/threads/compactComposerLayout.ts @@ -4,50 +4,47 @@ export function isWideComposerRow(rowWidth: number): boolean { return rowWidth >= WIDE_COMPOSER_ROW_WIDTH; } -/** Whether focus or the settings sheet opens the card, before the draft is considered. */ -export function composerOpensCardOnFocus(input: { +export interface CompactComposerSlot { readonly rowWidth: number; - readonly isFocused: boolean; - readonly isAndroid: boolean; - readonly keyboardVisible: boolean; - readonly keepsSettingsOpen: boolean; -}): boolean { - const opensCard = - (input.isFocused && (!input.isAndroid || input.keyboardVisible)) || input.keepsSettingsOpen; - return opensCard && input.rowWidth > 0 && !isWideComposerRow(input.rowWidth); -} - -export function isCompactComposerExpanded(input: { - readonly opensOnFocus: boolean; - readonly value: string; - readonly contentSize: { readonly width: number; readonly height: number }; + /** The editor's collapsed width, or 0 until it is measured at this row width. */ readonly compactWidth: number; - readonly lineHeight: number; - readonly fontSize: number; -}): boolean { - 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 so widening cannot trigger a collapse loop. - return ( - input.opensOnFocus || - (input.value.length > 0 && - (wraps || - (input.compactWidth > 0 && input.contentSize.width > input.compactWidth - input.fontSize))) - ); } /** A new row width forgets the compact slot so the next collapsed layout re-measures it. */ export function compactSlotAfterRowLayout( - slot: { readonly rowWidth: number; readonly compactWidth: number }, + slot: CompactComposerSlot, rowWidth: number, -): { readonly rowWidth: number; readonly compactWidth: number } { +): CompactComposerSlot { return rowWidth === slot.rowWidth ? slot : { rowWidth, compactWidth: 0 }; } -export function composerModelIconOnly(input: { - readonly isExpanded: boolean; - readonly rowWidth: number; - readonly attachmentCount: number; -}): boolean { - return !input.isExpanded && (!isWideComposerRow(input.rowWidth) || input.attachmentCount > 0); +export function resolveCompactComposerLayout(input: { + readonly slot: CompactComposerSlot; + readonly isFocused: boolean; + readonly isAndroid: boolean; + /** Android's Back hides the keyboard without blurring the editor. */ + readonly keyboardShowing: 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 } { + const { rowWidth, compactWidth } = input.slot; + const isWide = isWideComposerRow(rowWidth); + const opensOnFocus = + rowWidth > 0 && + !isWide && + ((input.isFocused && (!input.isAndroid || input.keyboardShowing)) || 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)); + return { + isExpanded, + modelIconOnly: !isExpanded && (!isWide || input.rowAttachmentCount > 0), + }; } 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); +} From de1ddf883ef357b948e037f582ad1711f8cc0e67 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 08:12:20 +0000 Subject: [PATCH 16/20] fix(mobile): wide composers re-measure when the model label resizes with the card open On a wide row the model label sits beside the compact slot, which is only measured while collapsed. Choosing a shorter model or reasoning label with the card open kept the old, narrower slot, so a draft that now fit stayed in the card. Forget the slot when the label resizes while the card stays open; layouts that open or close the card only record the width so they cannot loop. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/ThreadComposer.tsx | 18 ++++++-- .../threads/compactComposerLayout.test.ts | 39 ++++++++++++++-- .../features/threads/compactComposerLayout.ts | 46 +++++++++++++++---- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index a5a2c05e80db..f3941b82da72 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -107,6 +107,8 @@ import { } from "../voice-input/ComposerDictationControl"; import { useVoiceInputController } from "../voice-input/useVoiceInputController"; import { + EMPTY_COMPACT_COMPOSER_SLOT, + compactSlotAfterLabelLayout, compactSlotAfterRowLayout, isWideComposerRow, resolveCompactComposerLayout, @@ -147,7 +149,7 @@ export function useCompactComposerEditor( }, ) { const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); - const [slot, setSlot] = useState({ rowWidth: 0, compactWidth: 0 }); + const [slot, setSlot] = useState(EMPTY_COMPACT_COMPOSER_SLOT); const keyboardShowing = useAndroidKeyboardShowing(); const contentHeight = contentSize.height; const rowWidth = useSharedValue(0); @@ -156,7 +158,7 @@ export function useCompactComposerEditor( 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 } = resolveCompactComposerLayout({ + const { isExpanded, modelIconOnly, labelShownAtRest } = resolveCompactComposerLayout({ ...state, slot, isAndroid: Platform.OS === "android", @@ -204,6 +206,16 @@ export function useCompactComposerEditor( ); } }, + 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, @@ -800,7 +812,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ) : null; const modelControl = ( - + { @@ -71,7 +81,7 @@ describe("compact composer layout", () => { }); it("re-measures the compact slot when the row widens so the card can close", () => { - const narrowSlot = { rowWidth: NARROW, compactWidth: 200 }; + 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, @@ -85,4 +95,25 @@ describe("compact composer layout", () => { 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. + slot = compactSlotAfterLabelLayout(slot, { ...label, width: 240, isExpanded: false }); + slot = compactSlotAfterLabelLayout(slot, { ...label, width: 200 }); + expect(slot.compactWidth).toBe(320); + + slot = compactSlotAfterLabelLayout(slot, { ...label, width: 160 }); + expect(composer({ ...line, compactWidth: slot.compactWidth }).isExpanded).toBe(false); + }); }); diff --git a/apps/mobile/src/features/threads/compactComposerLayout.ts b/apps/mobile/src/features/threads/compactComposerLayout.ts index e9452ced8ba0..3c289026858e 100644 --- a/apps/mobile/src/features/threads/compactComposerLayout.ts +++ b/apps/mobile/src/features/threads/compactComposerLayout.ts @@ -1,21 +1,47 @@ -export const WIDE_COMPOSER_ROW_WIDTH = 500; +const WIDE_COMPOSER_ROW_WIDTH = 500; export function isWideComposerRow(rowWidth: number): boolean { return rowWidth >= WIDE_COMPOSER_ROW_WIDTH; } -export interface CompactComposerSlot { +interface CompactComposerSlot { readonly rowWidth: number; - /** The editor's collapsed width, or 0 until it is measured at this row width. */ + /** The editor's collapsed width, or 0 until it is measured again. */ readonly compactWidth: number; + readonly label: { readonly width: number; readonly isExpanded: boolean }; } +export const EMPTY_COMPACT_COMPOSER_SLOT: CompactComposerSlot = { + rowWidth: 0, + compactWidth: 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 : { rowWidth, compactWidth: 0 }; + 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 resizes while the card stays open forgets it. 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 resizedWhileOpen = label.shownAtRest && label.isExpanded && previous.isExpanded; + return { + ...slot, + label: { width: label.width, isExpanded: label.isExpanded }, + compactWidth: resizedWhileOpen ? 0 : slot.compactWidth, + }; } export function resolveCompactComposerLayout(input: { @@ -31,7 +57,11 @@ export function resolveCompactComposerLayout(input: { readonly fontSize: number; /** Thumbnails shown inside the collapsed row, beside the model control. */ readonly rowAttachmentCount: number; -}): { readonly isExpanded: boolean; readonly modelIconOnly: boolean } { +}): { + readonly isExpanded: boolean; + readonly modelIconOnly: boolean; + readonly labelShownAtRest: boolean; +} { const { rowWidth, compactWidth } = input.slot; const isWide = isWideComposerRow(rowWidth); const opensOnFocus = @@ -43,8 +73,6 @@ export function resolveCompactComposerLayout(input: { // 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)); - return { - isExpanded, - modelIconOnly: !isExpanded && (!isWide || input.rowAttachmentCount > 0), - }; + const labelShownAtRest = isWide && input.rowAttachmentCount === 0; + return { isExpanded, modelIconOnly: !isExpanded && !labelShownAtRest, labelShownAtRest }; } From 267b0dd21ba44a45591f397d21cccb3ea6c39091 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 08:30:39 +0000 Subject: [PATCH 17/20] fix(mobile): dictation from a focused narrow composer keeps the row height Dictation hides the editor without blurring it, so focus kept the card open as an empty band above the dictation toolbar. Focus no longer opens the card while dictation is showing. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/ThreadComposer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index f3941b82da72..fc05523146fb 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -607,7 +607,8 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const usesLargeControls = useSidebarWorkspaceTabs() === "picker" || (Platform.OS === "ios" && Platform.isPad); const compactEditor = useCompactComposerEditor(bodyText, draftMessage, { - isFocused, + // 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, }); From 52ad83f2697c084612dd907cc0a942b77d801338 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 08:37:22 +0000 Subject: [PATCH 18/20] fix(mobile): a wider model label no longer bounces an open wide-row card A label that grows while the card is open, such as the summary switching to Ultrathink as the prompt mentions it, only narrows the compact slot, so the card stays correct. Forget the slot only when the label shrinks. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/compactComposerLayout.test.ts | 4 +++- .../src/features/threads/compactComposerLayout.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/compactComposerLayout.test.ts b/apps/mobile/src/features/threads/compactComposerLayout.test.ts index e3010dc8e206..d589a2ac1e2d 100644 --- a/apps/mobile/src/features/threads/compactComposerLayout.test.ts +++ b/apps/mobile/src/features/threads/compactComposerLayout.test.ts @@ -108,9 +108,11 @@ describe("compact composer layout", () => { 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. + // 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 }); diff --git a/apps/mobile/src/features/threads/compactComposerLayout.ts b/apps/mobile/src/features/threads/compactComposerLayout.ts index 3c289026858e..e1ea3a06628f 100644 --- a/apps/mobile/src/features/threads/compactComposerLayout.ts +++ b/apps/mobile/src/features/threads/compactComposerLayout.ts @@ -27,8 +27,9 @@ export function compactSlotAfterRowLayout( /** * A label shown at rest shares the row with the compact slot, which is only measured while - * collapsed, so a label that resizes while the card stays open forgets it. Layouts that - * open or close the card only record the width: resetting on them would loop. + * 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, @@ -36,11 +37,12 @@ export function compactSlotAfterLabelLayout( ): CompactComposerSlot { const previous = slot.label; if (previous.width === label.width && previous.isExpanded === label.isExpanded) return slot; - const resizedWhileOpen = label.shownAtRest && label.isExpanded && previous.isExpanded; + const shrankWhileOpen = + label.shownAtRest && label.isExpanded && previous.isExpanded && label.width < previous.width; return { ...slot, label: { width: label.width, isExpanded: label.isExpanded }, - compactWidth: resizedWhileOpen ? 0 : slot.compactWidth, + compactWidth: shrankWhileOpen ? 0 : slot.compactWidth, }; } From 9bb5ad261362d7464674d8c47b7c576e70c0b45d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 08:48:09 +0000 Subject: [PATCH 19/20] fix(mobile): removing a thumbnail with the card open no longer strands the draft in it The compact slot is measured only while collapsed, so a measurement taken beside a thumbnail outlived the thumbnail when it was removed with the card open. Each measurement now records how many thumbnails sat in the row and is ignored for any other count, so the next collapsed layout measures the slot again. Co-Authored-By: Claude Opus 5.5 --- .../mobile/src/features/threads/ThreadComposer.tsx | 5 ++++- .../features/threads/compactComposerLayout.test.ts | 14 ++++++++++++++ .../src/features/threads/compactComposerLayout.ts | 7 ++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index fc05523146fb..34bca3c16e4e 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -201,8 +201,11 @@ export function useCompactComposerEditor( editorX.set(x); editorWidth.set(width); const compactWidth = Math.max(0, width - 8); + const attachmentCount = state.rowAttachmentCount; setSlot((current) => - current.compactWidth === compactWidth ? current : { ...current, compactWidth }, + current.compactWidth === compactWidth && current.attachmentCount === attachmentCount + ? current + : { ...current, compactWidth, attachmentCount }, ); } }, diff --git a/apps/mobile/src/features/threads/compactComposerLayout.test.ts b/apps/mobile/src/features/threads/compactComposerLayout.test.ts index d589a2ac1e2d..b56fdafdcee4 100644 --- a/apps/mobile/src/features/threads/compactComposerLayout.test.ts +++ b/apps/mobile/src/features/threads/compactComposerLayout.test.ts @@ -23,6 +23,7 @@ function composer( readonly contentSize?: { readonly width: number; readonly height: number }; readonly compactWidth?: number; readonly attachmentCount?: number; + readonly measuredAttachmentCount?: number; } = {}, ) { const { isExpanded, modelIconOnly } = resolveCompactComposerLayout({ @@ -30,6 +31,7 @@ function composer( ...EMPTY_COMPACT_COMPOSER_SLOT, rowWidth: input.rowWidth ?? NARROW, compactWidth: input.compactWidth ?? 200, + attachmentCount: input.measuredAttachmentCount ?? input.attachmentCount ?? 0, }, isFocused: input.isFocused ?? false, isAndroid: input.isAndroid ?? false, @@ -118,4 +120,16 @@ describe("compact composer layout", () => { 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 index e1ea3a06628f..a385cb3a6bf5 100644 --- a/apps/mobile/src/features/threads/compactComposerLayout.ts +++ b/apps/mobile/src/features/threads/compactComposerLayout.ts @@ -8,12 +8,15 @@ 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 }, }; @@ -64,7 +67,9 @@ export function resolveCompactComposerLayout(input: { readonly modelIconOnly: boolean; readonly labelShownAtRest: boolean; } { - const { rowWidth, compactWidth } = input.slot; + const { rowWidth } = input.slot; + const compactWidth = + input.slot.attachmentCount === input.rowAttachmentCount ? input.slot.compactWidth : 0; const isWide = isWideComposerRow(rowWidth); const opensOnFocus = rowWidth > 0 && From a50ca1cc6408401db9a68a81378ab7c87a378d12 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 10:05:49 +0000 Subject: [PATCH 20/20] fix(mobile): the Android card no longer closes while focus returns from the model sheet The Android card waited for keyboardWillShow, so after the model sheet restored focus it closed until the keyboard arrived. Close it only when a keyboard that showed since focus hides (Back), so a refocus waiting for its keyboard keeps it. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/ThreadComposer.tsx | 9 +++++++-- .../features/threads/compactComposerLayout.test.ts | 12 ++++-------- .../src/features/threads/compactComposerLayout.ts | 5 ++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 34bca3c16e4e..59ee4347d174 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -151,6 +151,10 @@ export function useCompactComposerEditor( const [contentSize, setContentSize] = useState({ width: 0, height: 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); @@ -161,8 +165,7 @@ export function useCompactComposerEditor( const { isExpanded, modelIconOnly, labelShownAtRest } = resolveCompactComposerLayout({ ...state, slot, - isAndroid: Platform.OS === "android", - keyboardShowing, + keyboardDismissed: keyboardShownSinceFocus && !keyboardShowing, value, contentSize, lineHeight: text.lineHeight, @@ -209,6 +212,7 @@ export function useCompactComposerEditor( ); } }, + onEditorFocus: () => setKeyboardShownSinceFocus(false), onModelControlLayout: (event: { nativeEvent: { layout: { width: number } } }) => { const { width } = event.nativeEvent.layout; setSlot((current) => @@ -677,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); diff --git a/apps/mobile/src/features/threads/compactComposerLayout.test.ts b/apps/mobile/src/features/threads/compactComposerLayout.test.ts index b56fdafdcee4..00076b91352f 100644 --- a/apps/mobile/src/features/threads/compactComposerLayout.test.ts +++ b/apps/mobile/src/features/threads/compactComposerLayout.test.ts @@ -16,8 +16,7 @@ function composer( input: { readonly rowWidth?: number; readonly isFocused?: boolean; - readonly isAndroid?: boolean; - readonly keyboardVisible?: boolean; + readonly keyboardDismissed?: boolean; readonly keepsSettingsOpen?: boolean; readonly value?: string; readonly contentSize?: { readonly width: number; readonly height: number }; @@ -34,8 +33,7 @@ function composer( attachmentCount: input.measuredAttachmentCount ?? input.attachmentCount ?? 0, }, isFocused: input.isFocused ?? false, - isAndroid: input.isAndroid ?? false, - keyboardShowing: input.keyboardVisible ?? false, + keyboardDismissed: input.keyboardDismissed ?? false, keepsSettingsOpen: input.keepsSettingsOpen ?? false, value: input.value ?? "", contentSize: input.contentSize ?? { width: 0, height: LINE_HEIGHT }, @@ -50,10 +48,8 @@ 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 }); - expect(composer({ isFocused: true, isAndroid: true }).isExpanded).toBe(false); - expect(composer({ isFocused: true, isAndroid: true, keyboardVisible: true }).isExpanded).toBe( - true, - ); + // 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, diff --git a/apps/mobile/src/features/threads/compactComposerLayout.ts b/apps/mobile/src/features/threads/compactComposerLayout.ts index a385cb3a6bf5..ea15edc15757 100644 --- a/apps/mobile/src/features/threads/compactComposerLayout.ts +++ b/apps/mobile/src/features/threads/compactComposerLayout.ts @@ -52,9 +52,8 @@ export function compactSlotAfterLabelLayout( export function resolveCompactComposerLayout(input: { readonly slot: CompactComposerSlot; readonly isFocused: boolean; - readonly isAndroid: boolean; /** Android's Back hides the keyboard without blurring the editor. */ - readonly keyboardShowing: boolean; + readonly keyboardDismissed: boolean; readonly keepsSettingsOpen: boolean; readonly value: string; readonly contentSize: { readonly width: number; readonly height: number }; @@ -74,7 +73,7 @@ export function resolveCompactComposerLayout(input: { const opensOnFocus = rowWidth > 0 && !isWide && - ((input.isFocused && (!input.isAndroid || input.keyboardShowing)) || input.keepsSettingsOpen); + ((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.