diff --git a/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx b/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx index d4dbfcad2e2..795a8b479ad 100644 --- a/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx +++ b/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx @@ -120,6 +120,7 @@ export default function ChatTab() { const [activeSessionId, setActiveSessionId] = useState(null); const [selectedAgentId, setSelectedAgentId] = useState(null); const [agentPickerOpen, setAgentPickerOpen] = useState(false); + const [starterRequestId, setStarterRequestId] = useState(0); // Bridge to the chat-sessions formSheet route. Mirror local // activeSessionId into the store so the picker can render the current @@ -540,7 +541,10 @@ export default function ChatTab() { loading={messagesLoading} hasSessions={sessions.length > 0} agent={currentAgent} - onPickPrompt={(text) => setDraft(draftKey, text)} + onPickPrompt={(text) => { + setDraft(draftKey, text); + setStarterRequestId((id) => id + 1); + }} onQuickAction={(action) => handleSend(action.prompt, [], { clearDraft: false }) } @@ -566,6 +570,7 @@ export default function ChatTab() { allowStop={pendingTask?.status !== "queued"} disabled={disabled} disabledReason={disabledReason} + expandTrigger={starterRequestId ? String(starterRequestId) : null} /> diff --git a/apps/mobile/components/chat/chat-composer.tsx b/apps/mobile/components/chat/chat-composer.tsx index d3ddd5b80fb..d5724697fbe 100644 --- a/apps/mobile/components/chat/chat-composer.tsx +++ b/apps/mobile/components/chat/chat-composer.tsx @@ -57,6 +57,8 @@ interface Props { disabled?: boolean; /** When `disabled`, replaces the pill label with the reason. */ disabledReason?: string; + /** Expand and focus the input when a conversation starter is selected. */ + expandTrigger?: string | null; } const IS_IOS = process.env.EXPO_OS === "ios"; @@ -70,6 +72,7 @@ export function ChatComposer({ allowStop = true, disabled = false, disabledReason, + expandTrigger, }: Props) { const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug); const { t } = useT("chat"); @@ -119,6 +122,7 @@ export function ChatComposer({ isSending={sending} renderStop={allowStop ? () => : undefined} manageKeyboard={false} + expandTrigger={expandTrigger} /> ); } diff --git a/apps/mobile/components/composer/message-composer.tsx b/apps/mobile/components/composer/message-composer.tsx index cc13e79f729..d88068ff52f 100644 --- a/apps/mobile/components/composer/message-composer.tsx +++ b/apps/mobile/components/composer/message-composer.tsx @@ -66,6 +66,10 @@ import { type MentionChip, } from "@/components/issue/composer-attachment-row"; import { useT } from "@/lib/i18n"; +import { + shouldCollapseAfterBlur, + shouldWaitForInputLayout, +} from "@/lib/composer-focus-trigger"; export interface MessageComposerReplyTarget { actorName: string; @@ -185,6 +189,13 @@ export function MessageComposer({ const [internalText, setInternalText] = useState(""); const [attachments, setAttachments] = useState([]); const [submitting, setSubmitting] = useState(false); + const focusAfterInputLayout = useRef(false); + const triggerRevision = useRef(0); + const previousExpandTrigger = useRef(expandTrigger ?? null); + if (previousExpandTrigger.current !== (expandTrigger ?? null)) { + previousExpandTrigger.current = expandTrigger ?? null; + triggerRevision.current += 1; + } // Hybrid controlled / uncontrolled pattern (React-canonical). Chat // passes `value`/`onChangeText` for cross-session draft persistence; @@ -215,18 +226,28 @@ export function MessageComposer({ }; }, [clearMentions]); - // Auto-expand + focus when an `expandTrigger` changes. Comment uses - // this to react to the long-press → reply flow setting a reply target. + // Auto-expand + focus when an `expandTrigger` changes. Wait for the + // newly-mounted input to complete native layout before requesting focus; + // Android can accept focus before its input view is ready without showing + // the soft keyboard. const triggerSeen = useRef(null); - if ( - expandTrigger && - triggerSeen.current !== expandTrigger && - !disabled - ) { + useEffect(() => { + if (!expandTrigger || triggerSeen.current === expandTrigger || disabled) { + return; + } triggerSeen.current = expandTrigger; + focusAfterInputLayout.current = shouldWaitForInputLayout(expanded); setExpanded(true); + if (expanded) { + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [expandTrigger, disabled, expanded]); + + const handleInputLayout = useCallback(() => { + if (!focusAfterInputLayout.current) return; + focusAfterInputLayout.current = false; requestAnimationFrame(() => inputRef.current?.focus()); - } + }, []); const hasInFlightUpload = attachments.some((a) => a.status === "uploading"); const canSend = @@ -449,12 +470,20 @@ export function MessageComposer({ * IconButton tap (which briefly resigns first responder) doesn't * trigger a collapse before its onPress runs. */ const onBlur = useCallback(() => { + const triggerRevisionAtBlur = triggerRevision.current; setTimeout(() => { - const empty = + const isEmpty = text.trim().length === 0 && attachments.length === 0 && mentions.length === 0; - if (empty && !inputRef.current?.isFocused()) { + if ( + shouldCollapseAfterBlur({ + isEmpty, + isFocused: !!inputRef.current?.isFocused(), + triggerRevisionAtBlur, + currentTriggerRevision: triggerRevision.current, + }) + ) { setExpanded(false); onClearReplyTarget?.(); } @@ -547,6 +576,7 @@ export function MessageComposer({ s.currentWorkspaceSlug); const replyTarget = useReplyTargetStore((s) => s.target); const clearReplyTarget = useReplyTargetStore((s) => s.clear); + const composerReplyProps = getReplyTargetComposerProps(replyTarget); const onSubmit = useCallback( async ({ @@ -61,16 +63,9 @@ export function InlineCommentComposer({ issueId }: { issueId: string }) { placeholder={t("comments.composer_placeholder")} pillLabel={t("comments.composer_pill")} pillIcon="chatbubble-ellipses-outline" - replyTarget={ - replyTarget - ? { - actorName: replyTarget.actorName, - preview: replyTarget.preview, - } - : null - } + replyTarget={composerReplyProps.replyTarget} onClearReplyTarget={clearReplyTarget} - expandTrigger={replyTarget?.commentId ?? null} + expandTrigger={composerReplyProps.expandTrigger} /> ); } diff --git a/apps/mobile/lib/composer-focus-trigger.test.ts b/apps/mobile/lib/composer-focus-trigger.test.ts new file mode 100644 index 00000000000..5b19a7ee9b3 --- /dev/null +++ b/apps/mobile/lib/composer-focus-trigger.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { + shouldCollapseAfterBlur, + shouldWaitForInputLayout, +} from "./composer-focus-trigger"; + +describe("composer expand trigger focus", () => { + it("waits for native input layout when the first trigger expands the composer", () => { + expect(shouldWaitForInputLayout(false)).toBe(true); + }); + + it("requests focus immediately for another starter selection while expanded", () => { + expect(shouldWaitForInputLayout(true)).toBe(false); + }); + + it("requests focus immediately when the reply target changes while expanded", () => { + expect(shouldWaitForInputLayout(true)).toBe(false); + }); + + it("does not let a delayed blur clear a reply target selected afterward", () => { + expect( + shouldCollapseAfterBlur({ + isEmpty: true, + isFocused: false, + triggerRevisionAtBlur: 3, + currentTriggerRevision: 4, + }), + ).toBe(false); + }); + + it("collapses after blur only while the target is unchanged and empty", () => { + expect( + shouldCollapseAfterBlur({ + isEmpty: true, + isFocused: false, + triggerRevisionAtBlur: 3, + currentTriggerRevision: 3, + }), + ).toBe(true); + expect( + shouldCollapseAfterBlur({ + isEmpty: false, + isFocused: false, + triggerRevisionAtBlur: 3, + currentTriggerRevision: 3, + }), + ).toBe(false); + }); +}); diff --git a/apps/mobile/lib/composer-focus-trigger.ts b/apps/mobile/lib/composer-focus-trigger.ts new file mode 100644 index 00000000000..effab695920 --- /dev/null +++ b/apps/mobile/lib/composer-focus-trigger.ts @@ -0,0 +1,18 @@ +/** Whether an expand trigger must wait for the TextInput's first native layout. */ +export function shouldWaitForInputLayout(isExpanded: boolean): boolean { + return !isExpanded; +} + +/** Ignore delayed blur cleanup when the reply target changed in the meantime. */ +export function shouldCollapseAfterBlur(args: { + isEmpty: boolean; + isFocused: boolean; + triggerRevisionAtBlur: number; + currentTriggerRevision: number; +}): boolean { + return ( + args.isEmpty && + !args.isFocused && + args.triggerRevisionAtBlur === args.currentTriggerRevision + ); +} diff --git a/apps/mobile/lib/reply-target-composer.test.ts b/apps/mobile/lib/reply-target-composer.test.ts new file mode 100644 index 00000000000..0d4fa00a356 --- /dev/null +++ b/apps/mobile/lib/reply-target-composer.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; +import { useReplyTargetStore } from "@/data/stores/reply-target-store"; +import { getReplyTargetComposerProps } from "./reply-target-composer"; + +describe("reply target composer props", () => { + afterEach(() => useReplyTargetStore.getState().clear()); + + it("updates the reply preview and focus trigger when the target changes", () => { + useReplyTargetStore.getState().setTarget({ + commentId: "comment-old", + actorName: "Old author", + preview: "Old comment preview", + }); + expect( + getReplyTargetComposerProps(useReplyTargetStore.getState().target), + ).toEqual({ + replyTarget: { actorName: "Old author", preview: "Old comment preview" }, + expandTrigger: "comment-old", + }); + + useReplyTargetStore.getState().setTarget({ + commentId: "comment-new", + actorName: "New author", + preview: "New comment preview", + }); + expect( + getReplyTargetComposerProps(useReplyTargetStore.getState().target), + ).toEqual({ + replyTarget: { actorName: "New author", preview: "New comment preview" }, + expandTrigger: "comment-new", + }); + }); +}); diff --git a/apps/mobile/lib/reply-target-composer.ts b/apps/mobile/lib/reply-target-composer.ts new file mode 100644 index 00000000000..b5aae7bdd61 --- /dev/null +++ b/apps/mobile/lib/reply-target-composer.ts @@ -0,0 +1,11 @@ +import type { ReplyTarget } from "@/data/stores/reply-target-store"; + +/** Convert the active target into the preview and focus key consumed by the composer. */ +export function getReplyTargetComposerProps(target: ReplyTarget | null) { + return { + replyTarget: target + ? { actorName: target.actorName, preview: target.preview } + : null, + expandTrigger: target?.commentId ?? null, + }; +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ff02e5c0a8d..a783c05faa8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -86,7 +86,7 @@ }, "devDependencies": { "@babel/core": "^7.25.0", - "@types/react": "~19.0.0", + "@types/react": "catalog:", "cross-env": "^7.0.3", "dotenv-cli": "^7.4.4", "eslint-config-expo": "~55.0.0",