From 0bd6d60ec739acd402b6c6ad4a35906641067fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=85=B8=E8=8F=9C=E7=BA=A2=E7=81=AF=E9=B1=BC?= Date: Thu, 8 Oct 2026 22:56:47 +0800 Subject: [PATCH 1/5] fix(mobile): focus composer after starter selection Co-authored-by: multica-agent --- .../app/(app)/[workspace]/(tabs)/chat.tsx | 7 +++++- apps/mobile/components/chat/chat-composer.tsx | 4 +++ .../components/composer/message-composer.tsx | 25 +++++++++++++------ apps/mobile/package.json | 2 +- 4 files changed, 28 insertions(+), 10 deletions(-) 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..71550b9839c 100644 --- a/apps/mobile/components/composer/message-composer.tsx +++ b/apps/mobile/components/composer/message-composer.tsx @@ -185,6 +185,7 @@ export function MessageComposer({ const [internalText, setInternalText] = useState(""); const [attachments, setAttachments] = useState([]); const [submitting, setSubmitting] = useState(false); + const focusAfterInputLayout = useRef(false); // Hybrid controlled / uncontrolled pattern (React-canonical). Chat // passes `value`/`onChangeText` for cross-session draft persistence; @@ -215,18 +216,25 @@ 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 = true; setExpanded(true); + }, [expandTrigger, disabled]); + + const handleInputLayout = useCallback(() => { + if (!focusAfterInputLayout.current) return; + focusAfterInputLayout.current = false; requestAnimationFrame(() => inputRef.current?.focus()); - } + }, []); const hasInFlightUpload = attachments.some((a) => a.status === "uploading"); const canSend = @@ -547,6 +555,7 @@ export function MessageComposer({ Date: Fri, 9 Oct 2026 14:06:08 +0800 Subject: [PATCH 2/5] fix(mobile): refocus composer on subsequent triggers Co-authored-by: multica-agent --- .../components/composer/focus-trigger.test.ts | 17 +++++++++++++++++ .../mobile/components/composer/focus-trigger.ts | 4 ++++ .../components/composer/message-composer.tsx | 8 ++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/components/composer/focus-trigger.test.ts create mode 100644 apps/mobile/components/composer/focus-trigger.ts diff --git a/apps/mobile/components/composer/focus-trigger.test.ts b/apps/mobile/components/composer/focus-trigger.test.ts new file mode 100644 index 00000000000..9cce88eeb7f --- /dev/null +++ b/apps/mobile/components/composer/focus-trigger.test.ts @@ -0,0 +1,17 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { shouldFocusAfterTrigger } from "./focus-trigger"; + +describe("composer expand trigger focus", () => { + it("waits for native input layout when the first trigger expands the composer", () => { + expect(shouldFocusAfterTrigger(false)).toBe(true); + }); + + it("requests focus immediately for another starter selection while expanded", () => { + expect(shouldFocusAfterTrigger(true)).toBe(false); + }); + + it("requests focus immediately when the reply target changes while expanded", () => { + expect(shouldFocusAfterTrigger(true)).toBe(false); + }); +}); diff --git a/apps/mobile/components/composer/focus-trigger.ts b/apps/mobile/components/composer/focus-trigger.ts new file mode 100644 index 00000000000..b1a2bfd2252 --- /dev/null +++ b/apps/mobile/components/composer/focus-trigger.ts @@ -0,0 +1,4 @@ +/** Whether an expand trigger must wait for the TextInput's first native layout. */ +export function shouldFocusAfterTrigger(isExpanded: boolean): boolean { + return !isExpanded; +} diff --git a/apps/mobile/components/composer/message-composer.tsx b/apps/mobile/components/composer/message-composer.tsx index 71550b9839c..f02eb89b23c 100644 --- a/apps/mobile/components/composer/message-composer.tsx +++ b/apps/mobile/components/composer/message-composer.tsx @@ -66,6 +66,7 @@ import { type MentionChip, } from "@/components/issue/composer-attachment-row"; import { useT } from "@/lib/i18n"; +import { shouldFocusAfterTrigger } from "@/components/composer/focus-trigger"; export interface MessageComposerReplyTarget { actorName: string; @@ -226,9 +227,12 @@ export function MessageComposer({ return; } triggerSeen.current = expandTrigger; - focusAfterInputLayout.current = true; + focusAfterInputLayout.current = shouldFocusAfterTrigger(expanded); setExpanded(true); - }, [expandTrigger, disabled]); + if (expanded) { + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [expandTrigger, disabled, expanded]); const handleInputLayout = useCallback(() => { if (!focusAfterInputLayout.current) return; From e71743ffc5f4dec822f363c1130b8d1de48ef799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=85=B8=E8=8F=9C=E7=BA=A2=E7=81=AF=E9=B1=BC?= Date: Fri, 9 Oct 2026 14:07:12 +0800 Subject: [PATCH 3/5] refactor(mobile): clarify focus layout gate Co-authored-by: multica-agent --- apps/mobile/components/composer/focus-trigger.test.ts | 8 ++++---- apps/mobile/components/composer/focus-trigger.ts | 2 +- apps/mobile/components/composer/message-composer.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/mobile/components/composer/focus-trigger.test.ts b/apps/mobile/components/composer/focus-trigger.test.ts index 9cce88eeb7f..190b9d2a72b 100644 --- a/apps/mobile/components/composer/focus-trigger.test.ts +++ b/apps/mobile/components/composer/focus-trigger.test.ts @@ -1,17 +1,17 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { shouldFocusAfterTrigger } from "./focus-trigger"; +import { shouldWaitForInputLayout } from "./focus-trigger"; describe("composer expand trigger focus", () => { it("waits for native input layout when the first trigger expands the composer", () => { - expect(shouldFocusAfterTrigger(false)).toBe(true); + expect(shouldWaitForInputLayout(false)).toBe(true); }); it("requests focus immediately for another starter selection while expanded", () => { - expect(shouldFocusAfterTrigger(true)).toBe(false); + expect(shouldWaitForInputLayout(true)).toBe(false); }); it("requests focus immediately when the reply target changes while expanded", () => { - expect(shouldFocusAfterTrigger(true)).toBe(false); + expect(shouldWaitForInputLayout(true)).toBe(false); }); }); diff --git a/apps/mobile/components/composer/focus-trigger.ts b/apps/mobile/components/composer/focus-trigger.ts index b1a2bfd2252..f2f88bd68c3 100644 --- a/apps/mobile/components/composer/focus-trigger.ts +++ b/apps/mobile/components/composer/focus-trigger.ts @@ -1,4 +1,4 @@ /** Whether an expand trigger must wait for the TextInput's first native layout. */ -export function shouldFocusAfterTrigger(isExpanded: boolean): boolean { +export function shouldWaitForInputLayout(isExpanded: boolean): boolean { return !isExpanded; } diff --git a/apps/mobile/components/composer/message-composer.tsx b/apps/mobile/components/composer/message-composer.tsx index f02eb89b23c..c35b2fec538 100644 --- a/apps/mobile/components/composer/message-composer.tsx +++ b/apps/mobile/components/composer/message-composer.tsx @@ -66,7 +66,7 @@ import { type MentionChip, } from "@/components/issue/composer-attachment-row"; import { useT } from "@/lib/i18n"; -import { shouldFocusAfterTrigger } from "@/components/composer/focus-trigger"; +import { shouldWaitForInputLayout } from "@/components/composer/focus-trigger"; export interface MessageComposerReplyTarget { actorName: string; @@ -227,7 +227,7 @@ export function MessageComposer({ return; } triggerSeen.current = expandTrigger; - focusAfterInputLayout.current = shouldFocusAfterTrigger(expanded); + focusAfterInputLayout.current = shouldWaitForInputLayout(expanded); setExpanded(true); if (expanded) { requestAnimationFrame(() => inputRef.current?.focus()); From 4e7b1d8e46699a1be12d32467a16251d6ada5604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=85=B8=E8=8F=9C=E7=BA=A2=E7=81=AF=E9=B1=BC?= Date: Fri, 9 Oct 2026 14:07:47 +0800 Subject: [PATCH 4/5] test(mobile): include composer focus regression cases Co-authored-by: multica-agent --- apps/mobile/components/composer/message-composer.tsx | 2 +- .../composer-focus-trigger.test.ts} | 2 +- .../composer/focus-trigger.ts => lib/composer-focus-trigger.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename apps/mobile/{components/composer/focus-trigger.test.ts => lib/composer-focus-trigger.test.ts} (89%) rename apps/mobile/{components/composer/focus-trigger.ts => lib/composer-focus-trigger.ts} (100%) diff --git a/apps/mobile/components/composer/message-composer.tsx b/apps/mobile/components/composer/message-composer.tsx index c35b2fec538..b142baead71 100644 --- a/apps/mobile/components/composer/message-composer.tsx +++ b/apps/mobile/components/composer/message-composer.tsx @@ -66,7 +66,7 @@ import { type MentionChip, } from "@/components/issue/composer-attachment-row"; import { useT } from "@/lib/i18n"; -import { shouldWaitForInputLayout } from "@/components/composer/focus-trigger"; +import { shouldWaitForInputLayout } from "@/lib/composer-focus-trigger"; export interface MessageComposerReplyTarget { actorName: string; diff --git a/apps/mobile/components/composer/focus-trigger.test.ts b/apps/mobile/lib/composer-focus-trigger.test.ts similarity index 89% rename from apps/mobile/components/composer/focus-trigger.test.ts rename to apps/mobile/lib/composer-focus-trigger.test.ts index 190b9d2a72b..3f3930dc910 100644 --- a/apps/mobile/components/composer/focus-trigger.test.ts +++ b/apps/mobile/lib/composer-focus-trigger.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { shouldWaitForInputLayout } from "./focus-trigger"; +import { shouldWaitForInputLayout } from "./composer-focus-trigger"; describe("composer expand trigger focus", () => { it("waits for native input layout when the first trigger expands the composer", () => { diff --git a/apps/mobile/components/composer/focus-trigger.ts b/apps/mobile/lib/composer-focus-trigger.ts similarity index 100% rename from apps/mobile/components/composer/focus-trigger.ts rename to apps/mobile/lib/composer-focus-trigger.ts From 77163360d854ffa9bb3294b4f6794038e1c777bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=85=B8=E8=8F=9C=E7=BA=A2=E7=81=AF=E9=B1=BC?= Date: Fri, 9 Oct 2026 20:28:34 +0800 Subject: [PATCH 5/5] fix(mobile): preserve reply target after composer blur Co-authored-by: multica-agent --- .../components/composer/message-composer.tsx | 23 ++++++++++-- .../issue/inline-comment-composer.tsx | 13 +++---- .../mobile/lib/composer-focus-trigger.test.ts | 35 ++++++++++++++++++- apps/mobile/lib/composer-focus-trigger.ts | 14 ++++++++ apps/mobile/lib/reply-target-composer.test.ts | 34 ++++++++++++++++++ apps/mobile/lib/reply-target-composer.ts | 11 ++++++ 6 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/lib/reply-target-composer.test.ts create mode 100644 apps/mobile/lib/reply-target-composer.ts diff --git a/apps/mobile/components/composer/message-composer.tsx b/apps/mobile/components/composer/message-composer.tsx index b142baead71..d88068ff52f 100644 --- a/apps/mobile/components/composer/message-composer.tsx +++ b/apps/mobile/components/composer/message-composer.tsx @@ -66,7 +66,10 @@ import { type MentionChip, } from "@/components/issue/composer-attachment-row"; import { useT } from "@/lib/i18n"; -import { shouldWaitForInputLayout } from "@/lib/composer-focus-trigger"; +import { + shouldCollapseAfterBlur, + shouldWaitForInputLayout, +} from "@/lib/composer-focus-trigger"; export interface MessageComposerReplyTarget { actorName: string; @@ -187,6 +190,12 @@ export function MessageComposer({ 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; @@ -461,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?.(); } diff --git a/apps/mobile/components/issue/inline-comment-composer.tsx b/apps/mobile/components/issue/inline-comment-composer.tsx index 5d155007b65..9298ce5668e 100644 --- a/apps/mobile/components/issue/inline-comment-composer.tsx +++ b/apps/mobile/components/issue/inline-comment-composer.tsx @@ -18,6 +18,7 @@ import { useReplyTargetStore } from "@/data/stores/reply-target-store"; import { useWorkspaceStore } from "@/data/workspace-store"; import { MessageComposer } from "@/components/composer/message-composer"; import { useT } from "@/lib/i18n"; +import { getReplyTargetComposerProps } from "@/lib/reply-target-composer"; export function InlineCommentComposer({ issueId }: { issueId: string }) { const createComment = useCreateComment(issueId); @@ -25,6 +26,7 @@ export function InlineCommentComposer({ issueId }: { issueId: string }) { const wsSlug = useWorkspaceStore((s) => 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 index 3f3930dc910..5b19a7ee9b3 100644 --- a/apps/mobile/lib/composer-focus-trigger.test.ts +++ b/apps/mobile/lib/composer-focus-trigger.test.ts @@ -1,6 +1,9 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { shouldWaitForInputLayout } from "./composer-focus-trigger"; +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", () => { @@ -14,4 +17,34 @@ describe("composer expand trigger focus", () => { 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 index f2f88bd68c3..effab695920 100644 --- a/apps/mobile/lib/composer-focus-trigger.ts +++ b/apps/mobile/lib/composer-focus-trigger.ts @@ -2,3 +2,17 @@ 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, + }; +}