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:
gsxdsm
2026-07-01 01:04:02 -07:00
parent 03160ebac0
commit d8f3cc64ae
5 changed files with 194 additions and 6 deletions

View 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.

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();

View File

@@ -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();