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 { entries, loading, loadMore, hasMore, loadingMore } = useAgentLogs(task.id, active, projectId);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const sendingRef = useRef(false);
|
||||||
const [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]);
|
const [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]);
|
||||||
const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true);
|
const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true);
|
||||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -782,7 +783,8 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
|||||||
const handleSubmit = useCallback(async (event?: React.FormEvent) => {
|
const handleSubmit = useCallback(async (event?: React.FormEvent) => {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!text || sending) return;
|
if (!text || sendingRef.current) return;
|
||||||
|
sendingRef.current = true;
|
||||||
|
|
||||||
const latestTimestampMs = getLatestTranscriptTimestampMs(entries, userMessages);
|
const latestTimestampMs = getLatestTranscriptTimestampMs(entries, userMessages);
|
||||||
const optimisticCreatedAtMs = Math.max(Date.now(), latestTimestampMs + 1);
|
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));
|
setOptimisticMessages((current) => current.filter((message) => message.id !== optimisticMessage.id));
|
||||||
addToast(`Unable to send message: ${getErrorMessage(error)}`, "error");
|
addToast(`Unable to send message: ${getErrorMessage(error)}`, "error");
|
||||||
} finally {
|
} finally {
|
||||||
|
sendingRef.current = false;
|
||||||
setSending(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:
|
* FNXC:TaskDetailChat 2026-06-13-19:05:
|
||||||
@@ -843,6 +846,22 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
|||||||
void handleSubmit();
|
void handleSubmit();
|
||||||
}, [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 (
|
return (
|
||||||
<div className="task-chat-tab" data-testid="task-chat-tab">
|
<div className="task-chat-tab" data-testid="task-chat-tab">
|
||||||
{onToggleExpanded ? (
|
{onToggleExpanded ? (
|
||||||
@@ -956,6 +975,8 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
|||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
aria-label={sending ? t("taskChat.sending", "Sending") : t("common:actions.send", "Send")}
|
aria-label={sending ? t("taskChat.sending", "Sending") : t("common:actions.send", "Send")}
|
||||||
title={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" />}
|
{sending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [composerState, setComposerState] = useState<ComposerState>("idle");
|
const [composerState, setComposerState] = useState<ComposerState>("idle");
|
||||||
|
const composerStateRef = useRef<ComposerState>("idle");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [historyLoaded, setHistoryLoaded] = useState(false);
|
const [historyLoaded, setHistoryLoaded] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -295,6 +296,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
setSessionId(null);
|
setSessionId(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
|
composerStateRef.current = "idle";
|
||||||
setComposerState("idle");
|
setComposerState("idle");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHistoryLoaded(false);
|
setHistoryLoaded(false);
|
||||||
@@ -340,7 +342,8 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
|
|
||||||
const sendMessageContent = useCallback(async (messageContent: string) => {
|
const sendMessageContent = useCallback(async (messageContent: string) => {
|
||||||
const content = messageContent.trim();
|
const content = messageContent.trim();
|
||||||
if (!content || composerState === "sending") return;
|
if (!content || composerStateRef.current === "sending") return;
|
||||||
|
composerStateRef.current = "sending";
|
||||||
|
|
||||||
const streamRequestId = streamRequestRef.current + 1;
|
const streamRequestId = streamRequestRef.current + 1;
|
||||||
streamRequestRef.current = streamRequestId;
|
streamRequestRef.current = streamRequestId;
|
||||||
@@ -406,6 +409,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
},
|
},
|
||||||
onDone: (data) => {
|
onDone: (data) => {
|
||||||
if (!isCurrentStreamRequest()) return;
|
if (!isCurrentStreamRequest()) return;
|
||||||
|
composerStateRef.current = "idle";
|
||||||
setComposerState("idle");
|
setComposerState("idle");
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
@@ -431,6 +435,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
if (!isCurrentStreamRequest()) return;
|
if (!isCurrentStreamRequest()) return;
|
||||||
const message = typeof streamError === "string" ? streamError : streamError.summary;
|
const message = typeof streamError === "string" ? streamError : streamError.summary;
|
||||||
setError(message || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"));
|
setError(message || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"));
|
||||||
|
composerStateRef.current = "idle";
|
||||||
setComposerState("idle");
|
setComposerState("idle");
|
||||||
streamRef.current = null;
|
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");
|
const message = getErrorMessage(err) || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond");
|
||||||
setError(message);
|
setError(message);
|
||||||
addToast(message, "error");
|
addToast(message, "error");
|
||||||
|
composerStateRef.current = "idle";
|
||||||
setComposerState("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]);
|
const sendMessage = useCallback(() => sendMessageContent(draft), [draft, sendMessageContent]);
|
||||||
|
|
||||||
@@ -457,6 +463,23 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
}, [sendMessage]);
|
}, [sendMessage]);
|
||||||
|
|
||||||
const canSend = draft.trim().length > 0 && composerState !== "sending";
|
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 showEmptyState = historyLoaded && !loading && !error && messages.length === 0;
|
||||||
const questionRenderStates = useMemo(() => buildPlannerQuestionRenderStates(messages), [messages]);
|
const questionRenderStates = useMemo(() => buildPlannerQuestionRenderStates(messages), [messages]);
|
||||||
const starterPrompts = useMemo(() => {
|
const starterPrompts = useMemo(() => {
|
||||||
@@ -627,7 +650,14 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
disabled={composerState === "sending"}
|
disabled={composerState === "sending"}
|
||||||
rows={1}
|
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" />}
|
{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>
|
<span>{composerState === "sending" ? t("taskDetail.plannerChat.sending", "Sending") : t("taskDetail.plannerChat.send", "Send")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -218,6 +218,14 @@ function expectDonePlaceholderWithoutGuidance() {
|
|||||||
expect(screen.getByPlaceholderText("Start a refinement task for this completed task")).toBeInTheDocument();
|
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) {
|
function restoreMetricDescriptor(name: "scrollTop" | "scrollHeight" | "clientHeight", descriptor: PropertyDescriptor | undefined) {
|
||||||
if (descriptor) {
|
if (descriptor) {
|
||||||
Object.defineProperty(HTMLElement.prototype, name, descriptor);
|
Object.defineProperty(HTMLElement.prototype, name, descriptor);
|
||||||
@@ -1456,6 +1464,63 @@ describe("TaskChatTab", () => {
|
|||||||
expect(input).toHaveValue("");
|
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 () => {
|
it("routes done-task composer sends to refineTask without replacing the current task", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import React from "react";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
|
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
|
||||||
|
|
||||||
@@ -56,6 +56,14 @@ function createDeferred<T>() {
|
|||||||
return { promise, resolve, reject };
|
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>> = {}) {
|
function renderPlannerChat(overrides: Partial<React.ComponentProps<typeof TaskPlannerChatTab>> = {}) {
|
||||||
return render(
|
return render(
|
||||||
<TaskPlannerChatTab
|
<TaskPlannerChatTab
|
||||||
@@ -425,6 +433,63 @@ describe("TaskPlannerChatTab", () => {
|
|||||||
await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument());
|
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 () => {
|
it("shows a recoverable error when the post-stream refresh fails", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user