FN-7356: fix mobile chat send taps
Make task chat and planner chat submit reliably from mobile send buttons. - Submit touch and pen send-button interactions on pointer down before soft-keyboard blur can consume the tap. - Add synchronous duplicate-send guards for task chat and planner chat send flows. - Cover one-tap mobile send behavior and duplicate prevention in chat component tests. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7356-mobile-chat-send.md | 7 +++ packages/dashboard/app/components/TaskChatTab.tsx | 25 +++++++- .../app/components/TaskPlannerChatTab.tsx | 36 +++++++++++- .../app/components/__tests__/TaskChatTab.test.tsx | 65 +++++++++++++++++++++ .../__tests__/TaskPlannerChatTab.test.tsx | 67 +++++++++++++++++++++- 5 files changed, 194 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7356 Fusion-Task-Lineage: 90b243e0-ae36-459d-bf01-112219e7865b Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7356-mobile-chat-send.md
Normal file
7
.changeset/fn-7356-mobile-chat-send.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Make mobile task-detail chat Send buttons submit on the first tap.
|
||||
category: fix
|
||||
dev: Adds touch-first send handling for Activity steering, done-task refinement, and planner Chat composers.
|
||||
@@ -569,6 +569,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
const { entries, loading, loadMore, hasMore, loadingMore } = useAgentLogs(task.id, active, projectId);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const sendingRef = useRef(false);
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]);
|
||||
const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
@@ -782,7 +783,8 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
const handleSubmit = useCallback(async (event?: React.FormEvent) => {
|
||||
event?.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!text || sending) return;
|
||||
if (!text || sendingRef.current) return;
|
||||
sendingRef.current = true;
|
||||
|
||||
const latestTimestampMs = getLatestTranscriptTimestampMs(entries, userMessages);
|
||||
const optimisticCreatedAtMs = Math.max(Date.now(), latestTimestampMs + 1);
|
||||
@@ -826,9 +828,10 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
setOptimisticMessages((current) => current.filter((message) => message.id !== optimisticMessage.id));
|
||||
addToast(`Unable to send message: ${getErrorMessage(error)}`, "error");
|
||||
} finally {
|
||||
sendingRef.current = false;
|
||||
setSending(false);
|
||||
}
|
||||
}, [addToast, draft, entries, isDoneTask, onTaskUpdated, projectId, sending, task.id, userMessages]);
|
||||
}, [addToast, draft, entries, isDoneTask, onTaskUpdated, projectId, task.id, userMessages]);
|
||||
|
||||
/**
|
||||
* FNXC:TaskDetailChat 2026-06-13-19:05:
|
||||
@@ -843,6 +846,22 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
void handleSubmit();
|
||||
}, [handleSubmit]);
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailChat 2026-07-01-00:00:
|
||||
Mobile soft keyboards can blur the focused composer textarea before the Send button receives a click, consuming the first tap. Touch/pen pointer-down submits immediately while the synchronous sendingRef guard preserves empty/disabled and duplicate-send behavior; mouse down only preserves focus so desktop click and keyboard submit semantics remain unchanged.
|
||||
*/
|
||||
const handleSendPointerDown = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.pointerType === "mouse") return;
|
||||
if (!canSend) return;
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}, [canSend, handleSubmit]);
|
||||
|
||||
const handleSendMouseDown = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!canSend) return;
|
||||
event.preventDefault();
|
||||
}, [canSend]);
|
||||
|
||||
return (
|
||||
<div className="task-chat-tab" data-testid="task-chat-tab">
|
||||
{onToggleExpanded ? (
|
||||
@@ -956,6 +975,8 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
disabled={!canSend}
|
||||
aria-label={sending ? t("taskChat.sending", "Sending") : t("common:actions.send", "Send")}
|
||||
title={sending ? t("taskChat.sending", "Sending") : t("common:actions.send", "Send")}
|
||||
onPointerDown={handleSendPointerDown}
|
||||
onMouseDown={handleSendMouseDown}
|
||||
>
|
||||
{sending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
|
||||
</button>
|
||||
|
||||
@@ -237,6 +237,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [composerState, setComposerState] = useState<ComposerState>("idle");
|
||||
const composerStateRef = useRef<ComposerState>("idle");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -295,6 +296,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
setSessionId(null);
|
||||
setMessages([]);
|
||||
setDraft("");
|
||||
composerStateRef.current = "idle";
|
||||
setComposerState("idle");
|
||||
setLoading(false);
|
||||
setHistoryLoaded(false);
|
||||
@@ -340,7 +342,8 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
|
||||
const sendMessageContent = useCallback(async (messageContent: string) => {
|
||||
const content = messageContent.trim();
|
||||
if (!content || composerState === "sending") return;
|
||||
if (!content || composerStateRef.current === "sending") return;
|
||||
composerStateRef.current = "sending";
|
||||
|
||||
const streamRequestId = streamRequestRef.current + 1;
|
||||
streamRequestRef.current = streamRequestId;
|
||||
@@ -406,6 +409,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
},
|
||||
onDone: (data) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
composerStateRef.current = "idle";
|
||||
setComposerState("idle");
|
||||
streamRef.current = null;
|
||||
if (data.message) {
|
||||
@@ -431,6 +435,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
const message = typeof streamError === "string" ? streamError : streamError.summary;
|
||||
setError(message || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"));
|
||||
composerStateRef.current = "idle";
|
||||
setComposerState("idle");
|
||||
streamRef.current = null;
|
||||
},
|
||||
@@ -444,9 +449,10 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
const message = getErrorMessage(err) || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond");
|
||||
setError(message);
|
||||
addToast(message, "error");
|
||||
composerStateRef.current = "idle";
|
||||
setComposerState("idle");
|
||||
}
|
||||
}, [addToast, composerState, modelPayload, projectId, refreshTaskAfterSteering, sessionId, task.id, t]);
|
||||
}, [addToast, modelPayload, projectId, refreshTaskAfterSteering, sessionId, task.id, t]);
|
||||
|
||||
const sendMessage = useCallback(() => sendMessageContent(draft), [draft, sendMessageContent]);
|
||||
|
||||
@@ -457,6 +463,23 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
}, [sendMessage]);
|
||||
|
||||
const canSend = draft.trim().length > 0 && composerState !== "sending";
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailPlannerChat 2026-07-01-00:00:
|
||||
Mobile soft keyboards can blur the focused planner-chat textarea before the Send button's click fires. Touch/pen pointer-down submits through the same planner Chat stream path with a synchronous composerStateRef duplicate guard; mouse down only preserves focus so desktop click and Enter behavior stay unchanged.
|
||||
*/
|
||||
const handleSendPointerDown = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.pointerType === "mouse") return;
|
||||
if (!canSend) return;
|
||||
event.preventDefault();
|
||||
void sendMessage();
|
||||
}, [canSend, sendMessage]);
|
||||
|
||||
const handleSendMouseDown = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!canSend) return;
|
||||
event.preventDefault();
|
||||
}, [canSend]);
|
||||
|
||||
const showEmptyState = historyLoaded && !loading && !error && messages.length === 0;
|
||||
const questionRenderStates = useMemo(() => buildPlannerQuestionRenderStates(messages), [messages]);
|
||||
const starterPrompts = useMemo(() => {
|
||||
@@ -627,7 +650,14 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
disabled={composerState === "sending"}
|
||||
rows={1}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary task-planner-chat-send" onClick={() => void sendMessage()} disabled={!canSend}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary task-planner-chat-send"
|
||||
onClick={() => void sendMessage()}
|
||||
onPointerDown={handleSendPointerDown}
|
||||
onMouseDown={handleSendMouseDown}
|
||||
disabled={!canSend}
|
||||
>
|
||||
{composerState === "sending" ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
|
||||
<span>{composerState === "sending" ? t("taskDetail.plannerChat.sending", "Sending") : t("taskDetail.plannerChat.send", "Send")}</span>
|
||||
</button>
|
||||
|
||||
@@ -218,6 +218,14 @@ function expectDonePlaceholderWithoutGuidance() {
|
||||
expect(screen.getByPlaceholderText("Start a refinement task for this completed task")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
function firstTapSendFromFocusedTextarea(input: HTMLElement, sendButton: HTMLElement) {
|
||||
input.focus();
|
||||
expect(input).toHaveFocus();
|
||||
fireEvent.pointerDown(sendButton, { pointerType: "touch" });
|
||||
fireEvent.blur(input);
|
||||
fireEvent.click(sendButton);
|
||||
}
|
||||
|
||||
function restoreMetricDescriptor(name: "scrollTop" | "scrollHeight" | "clientHeight", descriptor: PropertyDescriptor | undefined) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(HTMLElement.prototype, name, descriptor);
|
||||
@@ -1456,6 +1464,63 @@ describe("TaskChatTab", () => {
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("sends Activity steering exactly once on the first mobile tap while the textarea is focused", async () => {
|
||||
const updatedTask = makeTask();
|
||||
mockedAddSteeringComment.mockResolvedValue(updatedTask);
|
||||
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
||||
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
fireEvent.change(input, { target: { value: "First mobile tap steering" } });
|
||||
firstTapSendFromFocusedTextarea(input, screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "First mobile tap steering", "project-1");
|
||||
});
|
||||
expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1);
|
||||
expect(mockedRefineTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends done-task refinement exactly once on the first mobile tap while the textarea is focused", async () => {
|
||||
mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-222", column: "todo" }));
|
||||
render(<TaskChatTab task={makeTask({ column: "done", status: undefined })} projectId="project-1" active addToast={vi.fn()} />);
|
||||
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
fireEvent.change(input, { target: { value: "First mobile tap refinement" } });
|
||||
firstTapSendFromFocusedTextarea(input, screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "First mobile tap refinement", "project-1");
|
||||
});
|
||||
expect(mockedRefineTask).toHaveBeenCalledTimes(1);
|
||||
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps mobile first-tap guards for blank drafts and in-flight sends", async () => {
|
||||
const send = deferred<Task>();
|
||||
mockedAddSteeringComment.mockReturnValue(send.promise);
|
||||
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
||||
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
const sendButton = screen.getByRole("button", { name: "Send" });
|
||||
expect(sendButton).toBeDisabled();
|
||||
fireEvent.change(input, { target: { value: " \n " } });
|
||||
expect(sendButton).toBeDisabled();
|
||||
firstTapSendFromFocusedTextarea(input, sendButton);
|
||||
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(input, { target: { value: "Do not duplicate mobile tap" } });
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
firstTapSendFromFocusedTextarea(input, sendButton);
|
||||
fireEvent.pointerDown(sendButton, { pointerType: "touch" });
|
||||
fireEvent.click(sendButton);
|
||||
|
||||
await waitFor(() => expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {
|
||||
send.resolve(makeTask());
|
||||
await send.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it("routes done-task composer sends to refineTask without replacing the current task", async () => {
|
||||
const user = userEvent.setup();
|
||||
const addToast = vi.fn();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
|
||||
|
||||
@@ -56,6 +56,14 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function firstTapSendFromFocusedPlannerTextarea(input: HTMLElement, sendButton: HTMLElement) {
|
||||
input.focus();
|
||||
expect(input).toHaveFocus();
|
||||
fireEvent.pointerDown(sendButton, { pointerType: "touch" });
|
||||
fireEvent.blur(input);
|
||||
fireEvent.click(sendButton);
|
||||
}
|
||||
|
||||
function renderPlannerChat(overrides: Partial<React.ComponentProps<typeof TaskPlannerChatTab>> = {}) {
|
||||
return render(
|
||||
<TaskPlannerChatTab
|
||||
@@ -425,6 +433,63 @@ describe("TaskPlannerChatTab", () => {
|
||||
await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("sends planner Chat exactly once on the first mobile tap while the textarea is focused", async () => {
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
|
||||
renderPlannerChat({ projectId: "project-1" });
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
const input = screen.getByLabelText("Message planner chat");
|
||||
fireEvent.change(input, { target: { value: "First mobile tap planner message" } });
|
||||
firstTapSendFromFocusedPlannerTextarea(input, screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
|
||||
"FN-7310",
|
||||
{ modelProvider: "anthropic", modelId: "claude-plan" },
|
||||
"project-1",
|
||||
);
|
||||
});
|
||||
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledTimes(1);
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
"chat-planner",
|
||||
"First mobile tap planner message",
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
"project-1",
|
||||
{ taskId: "FN-7310" },
|
||||
);
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps planner mobile first-tap guards for blank drafts and in-flight sends", async () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
const input = screen.getByLabelText("Message planner chat");
|
||||
const sendButton = screen.getByRole("button", { name: "Send" });
|
||||
expect(sendButton).toBeDisabled();
|
||||
fireEvent.change(input, { target: { value: " \n " } });
|
||||
expect(sendButton).toBeDisabled();
|
||||
firstTapSendFromFocusedPlannerTextarea(input, sendButton);
|
||||
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(input, { target: { value: "Do not duplicate planner tap" } });
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
firstTapSendFromFocusedPlannerTextarea(input, sendButton);
|
||||
fireEvent.pointerDown(sendButton, { pointerType: "touch" });
|
||||
fireEvent.click(sendButton);
|
||||
|
||||
await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(1));
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
"chat-planner",
|
||||
"Do not duplicate planner tap",
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
undefined,
|
||||
{ taskId: "FN-7310" },
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a recoverable error when the post-stream refresh fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const addToast = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user