From e6bc30282f41dd54105f386bcbe08a1307872c1a Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 05:53:57 +0000 Subject: [PATCH 1/7] 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 f8751d970dc7..d0161da88191 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 } } }) => { @@ -600,8 +615,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 = @@ -859,7 +880,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 e11e46c51d90413e83fd394fadb9c20895c2b6f2 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 07:26:04 +0000 Subject: [PATCH 2/7] 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 4f1b92befaa3..63e3c9a3f897 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -412,6 +412,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 816e22f76ed3fb12268feb638f3154a557e718e1 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 07:32:56 +0000 Subject: [PATCH 3/7] 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 d0161da88191..578117ff34a9 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 da7eb0f09299245a3a27b99ae15b344970316a29 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 08:54:42 +0000 Subject: [PATCH 4/7] 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 63e3c9a3f897..4f1b92befaa3 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -412,17 +412,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 f7d577df03e8cdcc198a3dc72790cbc9537ddf9e Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 24 Oct 2026 00:08:51 +0000 Subject: [PATCH 5/7] 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 578117ff34a9..323502a9936a 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: { @@ -473,6 +476,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); @@ -623,10 +627,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 509d50cf9296c96431071ac55415f789d53ffe30 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 24 Oct 2026 01:42:36 +0000 Subject: [PATCH 6/7] fix(mobile): Android composer card closes with the keyboard useKeyboardState only refreshes isVisible on keyboardDidHide, so the Android card waited for the hide animation (and the long interactive back gesture) before collapsing. Track keyboardWillShow/keyboardWillHide instead. --- .../src/features/threads/ThreadComposer.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 323502a9936a..f90c688037d5 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"; @@ -463,6 +463,21 @@ export function ComposerSurface(props: { ); } +// useKeyboardState only flips isVisible on keyboardDidHide, after the hide animation; the will* +// events let the card leave with the keyboard instead of after it. +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; @@ -476,7 +491,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 34e55c2d321bf4a7b58d5fae2948335839c84bae Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 24 Oct 2026 01:46:08 +0000 Subject: [PATCH 7/7] chore(mobile): drop comment --- apps/mobile/src/features/threads/ThreadComposer.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index f90c688037d5..7b7bad5ee951 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -463,8 +463,6 @@ export function ComposerSurface(props: { ); } -// useKeyboardState only flips isVisible on keyboardDidHide, after the hide animation; the will* -// events let the card leave with the keyboard instead of after it. function useKeyboardShowing() { const [showing, setShowing] = useState(KeyboardController.isVisible); useEffect(() => {