Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export default function ChatTab() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(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
Expand Down Expand Up @@ -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 })
}
Expand All @@ -566,6 +570,7 @@ export default function ChatTab() {
allowStop={pendingTask?.status !== "queued"}
disabled={disabled}
disabledReason={disabledReason}
expandTrigger={starterRequestId ? String(starterRequestId) : null}
/>
</KeyboardAvoidingView>

Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/components/chat/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -70,6 +72,7 @@ export function ChatComposer({
allowStop = true,
disabled = false,
disabledReason,
expandTrigger,
}: Props) {
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const { t } = useT("chat");
Expand Down Expand Up @@ -119,6 +122,7 @@ export function ChatComposer({
isSending={sending}
renderStop={allowStop ? () => <StopButton onPress={handleStop} /> : undefined}
manageKeyboard={false}
expandTrigger={expandTrigger}
/>
);
}
Expand Down
50 changes: 40 additions & 10 deletions apps/mobile/components/composer/message-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -185,6 +189,13 @@ export function MessageComposer({
const [internalText, setInternalText] = useState("");
const [attachments, setAttachments] = useState<ComposerAttachmentItem[]>([]);
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;
Expand Down Expand Up @@ -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<string | null>(null);
if (
expandTrigger &&
triggerSeen.current !== expandTrigger &&
!disabled
) {
useEffect(() => {
if (!expandTrigger || triggerSeen.current === expandTrigger || disabled) {
return;
}
triggerSeen.current = expandTrigger;
focusAfterInputLayout.current = shouldWaitForInputLayout(expanded);
setExpanded(true);
Comment thread
Souredfish marked this conversation as resolved.
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 =
Expand Down Expand Up @@ -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?.();
}
Expand Down Expand Up @@ -547,6 +576,7 @@ export function MessageComposer({

<TextInput
ref={inputRef}
onLayout={handleInputLayout}
value={text}
onChangeText={setText}
onBlur={onBlur}
Expand Down
13 changes: 4 additions & 9 deletions apps/mobile/components/issue/inline-comment-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ 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);
const { t } = useT("issues");
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 ({
Expand Down Expand Up @@ -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}
/>
);
}
50 changes: 50 additions & 0 deletions apps/mobile/lib/composer-focus-trigger.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 18 additions & 0 deletions apps/mobile/lib/composer-focus-trigger.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
34 changes: 34 additions & 0 deletions apps/mobile/lib/reply-target-composer.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
11 changes: 11 additions & 0 deletions apps/mobile/lib/reply-target-composer.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading