FN-6576: align chat send touch dedupe
Align the direct and room chat mobile send controls around the same pointer/touch dedupe contract. - Add a per-gesture touch action latch separate from the trailing synthetic-click latch. - Apply the two-latch handling to direct send, room send, and send-to-stop transitions. - Cover repeated iOS taps, pointer/touch/click dedupe, and stop-button swap behavior in ChatView tests. - Document the shared mobile send dedupe behavior for direct and room chat. Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/ChatView.tsx | 70 ++++++++--- .../components/__tests__/ChatView.rooms.test.tsx | 30 +++++ .../app/components/__tests__/ChatView.test.tsx | 132 +++++++++++++++++++++ 4 files changed, 215 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-6576 Fusion-Task-Lineage: b8ef427f-99f0-48d8-8cb8-d72ac352bdeb
This commit is contained in:
@@ -271,7 +271,7 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are
|
||||
- Submitting the room composer calls `rooms.sendRoomMessage(...)`, which immediately inserts a temporary local user message and then posts to `POST /api/chat/rooms/:id/messages`.
|
||||
- The room composer clears immediately when send is dispatched so the user gets instant feedback; on success the optimistic message is reconciled with persisted server data and the transcript is refreshed to authoritative history.
|
||||
- On mobile, room threads use the same keyboard-aware thread anchoring as direct chat, keeping the composer pinned above the soft keyboard while typing.
|
||||
- On mobile, the room composer send button uses the same touch/pointer dedupe as direct chat: one tap dispatches exactly one room send even when the browser emits pointer, touch, and click events differently across iOS and Android.
|
||||
- On mobile, the room and direct composer send buttons use a two-latch touch/pointer dedupe: pointer/touch events claim only the current gesture, while a separate click latch consumes any trailing synthetic click. One tap dispatches exactly one send, a second iOS tap within the suppressed-click window still sends, and a send-to-stop button swap does not accidentally press stop.
|
||||
- The dashboard backend now orchestrates room responders on that POST: mentioned members are routed as direct responders, additional ambient members may reply (up to the room ambient responder cap), and each assistant reply is persisted with `senderAgentId` via `chatStore.addRoomMessage(...)`.
|
||||
- Room responders can intentionally stay silent by returning the `__SKIP__` sentinel; that sentinel is treated as a no-op and is never persisted, emitted over SSE, or rendered in room transcripts.
|
||||
- If room replies cannot be generated (for example no resolvable responders or all responders fail), the POST fails with an API error (HTTP 502) instead of silently returning only the user message.
|
||||
|
||||
@@ -1140,14 +1140,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const mentionCursorPosRef = useRef(0);
|
||||
const copyFeedbackTimeoutsRef = useRef<Map<string, number>>(new Map());
|
||||
const roomSendInFlightRef = useRef(false);
|
||||
// Mobile send-button tap latch. iOS suppresses the trailing synthetic click
|
||||
// after preventDefault() in the touch sequence, so the send must fire from
|
||||
// pointerdown/touchstart. This latch dedupes the multiple events of one tap
|
||||
// (pointerdown + touchstart, plus any surviving click) into a single send,
|
||||
// and self-clears on a timer so a suppressed click can't leave it stuck true
|
||||
// (which would swallow the next real tap and make the button look dead).
|
||||
/*
|
||||
FNXC:ChatSendDedupe 2026-06-17-08:36:
|
||||
FN-6576 refines FN-6563 by matching QuickChatFAB's two-latch touch contract: pointerdown/touchstart claim a per-input-task gesture so one mobile tap sends exactly once, while the separate 700ms latch is consumed only by a trailing click. A suppressed iOS click must never leave the long latch blocking the next tap; a send-to-stop DOM swap must consume the trailing click without swallowing a genuine later stop tap.
|
||||
*/
|
||||
const handledSendTouchRef = useRef(false);
|
||||
const handledSendTouchTimerRef = useRef<number | null>(null);
|
||||
const touchActionGestureRef = useRef(false);
|
||||
const mode = useViewportMode();
|
||||
const isMobile = mode === "mobile";
|
||||
const isTablet = mode === "tablet";
|
||||
@@ -1957,9 +1956,10 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
});
|
||||
}, [activeDraftKey]);
|
||||
|
||||
// Mark that a touch gesture already triggered the send so the trailing
|
||||
// onClick (if it survives) bails. Auto-resets so a suppressed click never
|
||||
// leaves the latch stuck.
|
||||
// Mark that a mobile pointer/touch handler already performed the action so
|
||||
// the trailing onClick (if it survives) bails. This long latch is intentionally
|
||||
// never consulted by pointerdown/touchstart, because iOS may suppress the
|
||||
// click that would consume it.
|
||||
const markHandledSendTouch = useCallback(() => {
|
||||
handledSendTouchRef.current = true;
|
||||
if (handledSendTouchTimerRef.current != null) {
|
||||
@@ -1971,6 +1971,18 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}, 700);
|
||||
}, []);
|
||||
|
||||
// Claim one input task's touch gesture. Real mobile taps can dispatch both
|
||||
// pointerdown and touchstart before React flushes state; only the first should
|
||||
// run the action, and the claim must clear before the next tap.
|
||||
const beginTouchActionGesture = useCallback(() => {
|
||||
if (touchActionGestureRef.current) return false;
|
||||
touchActionGestureRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
touchActionGestureRef.current = false;
|
||||
}, 0);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Consume the latch (cancelling its timer) so a trailing onClick bails once.
|
||||
const consumeHandledSendTouch = useCallback(() => {
|
||||
if (!handledSendTouchRef.current) return false;
|
||||
@@ -3092,7 +3104,27 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
{isStreaming ? (
|
||||
<button
|
||||
className="chat-input-stop"
|
||||
onClick={stopStreaming}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType && event.pointerType !== "mouse") {
|
||||
event.preventDefault();
|
||||
if (!beginTouchActionGesture()) return;
|
||||
markHandledSendTouch();
|
||||
stopStreaming();
|
||||
}
|
||||
}}
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
if (!beginTouchActionGesture()) return;
|
||||
markHandledSendTouch();
|
||||
stopStreaming();
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
if (consumeHandledSendTouch()) return;
|
||||
stopStreaming();
|
||||
}}
|
||||
aria-label={t("chat.stopGeneration", "Stop generation")}
|
||||
data-testid="chat-stop-btn"
|
||||
>
|
||||
@@ -3107,13 +3139,14 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
// iOS suppresses the trailing click after this preventDefault,
|
||||
// so fire the send here (deduped) rather than relying on onClick.
|
||||
event.preventDefault();
|
||||
if (handledSendTouchRef.current) return;
|
||||
if (!beginTouchActionGesture()) return;
|
||||
markHandledSendTouch();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
onTouchStart={() => {
|
||||
if (handledSendTouchRef.current) return;
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
if (!beginTouchActionGesture()) return;
|
||||
markHandledSendTouch();
|
||||
void handleSend();
|
||||
}}
|
||||
@@ -3739,19 +3772,20 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
type="button"
|
||||
className="chat-input-send"
|
||||
/*
|
||||
FNXC:ChatRoomSend 2026-06-17-02:56:
|
||||
FN-6563 requires the room composer send button to share the direct-chat touch/pointer dedupe contract: a single mobile tap must dispatch exactly one room send, even when iOS suppresses the trailing click after pointerdown preventDefault or Android emits pointerdown, touchstart, and click.
|
||||
FNXC:ChatRoomSend 2026-06-17-08:36:
|
||||
FN-6576 requires the room composer to share the direct-chat two-latch dedupe contract: pointerdown/touchstart use the short per-gesture claim, while the 700ms latch is reserved for a trailing click. This preserves room routing through handleSendDispatch() and ensures a second iOS tap within the suppressed-click window still dispatches exactly one room send.
|
||||
*/
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType && event.pointerType !== "mouse") {
|
||||
event.preventDefault();
|
||||
if (handledSendTouchRef.current) return;
|
||||
if (!beginTouchActionGesture()) return;
|
||||
markHandledSendTouch();
|
||||
void handleSendDispatch();
|
||||
}
|
||||
}}
|
||||
onTouchStart={() => {
|
||||
if (handledSendTouchRef.current) return;
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
if (!beginTouchActionGesture()) return;
|
||||
markHandledSendTouch();
|
||||
void handleSendDispatch();
|
||||
}}
|
||||
|
||||
@@ -653,6 +653,36 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
mediaSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("FN-6576 sends each of two consecutive room iOS taps within the click-latch window", async () => {
|
||||
const mediaSpy = mockMobileViewport();
|
||||
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
||||
setup({}, { sendRoomMessage, activeRoom: roomA });
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: "Room first" } });
|
||||
const firstSendButton = screen.getByTestId("chat-send-btn");
|
||||
await act(async () => {
|
||||
firstSendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" }));
|
||||
});
|
||||
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1));
|
||||
expect(sendRoomMessage).toHaveBeenLastCalledWith("Room first", { files: [] });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Room second" } });
|
||||
const secondSendButton = screen.getByTestId("chat-send-btn");
|
||||
await act(async () => {
|
||||
secondSendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" }));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(2));
|
||||
expect(sendRoomMessage).toHaveBeenLastCalledWith("Room second", { files: [] });
|
||||
mediaSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("FN-6563 sends a room message exactly once for a full Android tap sequence", async () => {
|
||||
const mediaSpy = mockMobileViewport();
|
||||
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -1575,6 +1575,38 @@ describe("ChatView", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-6576 sends each of two consecutive direct iOS taps within the click-latch window", async () => {
|
||||
const viewportSpy = mockViewportMode("mobile");
|
||||
const sendMessage = vi.fn();
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [],
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "Direct first" } });
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" });
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenLastCalledWith("Direct first", []);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Direct second" } });
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" });
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessage).toHaveBeenLastCalledWith("Direct second", []);
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("clears room composer on Enter after successful room send", async () => {
|
||||
localStorage.setItem("fusion:chat-scope", "rooms");
|
||||
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -2213,6 +2245,106 @@ describe("ChatView", () => {
|
||||
expect(stopStreaming).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("FN-6576 does not let a send gesture trailing click press the swapped stop button", async () => {
|
||||
const viewportSpy = mockViewportMode("mobile");
|
||||
const sendMessage = vi.fn();
|
||||
const stopStreaming = vi.fn();
|
||||
mockUseChat.mockImplementation(() => {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
return {
|
||||
...defaultChatState,
|
||||
activeSession: activeSessionFixture,
|
||||
sessions: [activeSessionFixture],
|
||||
filteredSessions: [activeSessionFixture],
|
||||
messages: [],
|
||||
isStreaming,
|
||||
sendMessage: (message, files) => {
|
||||
sendMessage(message, files);
|
||||
setIsStreaming(true);
|
||||
},
|
||||
stopStreaming,
|
||||
} satisfies UseChatReturn;
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Start streaming" } });
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" });
|
||||
fireEvent.touchStart(screen.getByTestId("chat-send-btn"));
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith("Start streaming", []);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("chat-stop-btn"));
|
||||
});
|
||||
expect(stopStreaming).not.toHaveBeenCalled();
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("FN-6576 allows a standalone mobile stop tap exactly once", async () => {
|
||||
const viewportSpy = mockViewportMode("mobile");
|
||||
const stopStreaming = vi.fn();
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [],
|
||||
isStreaming: true,
|
||||
stopStreaming,
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(screen.getByTestId("chat-stop-btn"), { pointerType: "touch" });
|
||||
fireEvent.touchStart(screen.getByTestId("chat-stop-btn"));
|
||||
fireEvent.click(screen.getByTestId("chat-stop-btn"));
|
||||
});
|
||||
expect(stopStreaming).toHaveBeenCalledTimes(1);
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("FN-6576 allows a genuine stop tap within the send click-latch window", async () => {
|
||||
const viewportSpy = mockViewportMode("mobile");
|
||||
const sendMessage = vi.fn();
|
||||
const stopStreaming = vi.fn();
|
||||
mockUseChat.mockImplementation(() => {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
return {
|
||||
...defaultChatState,
|
||||
activeSession: activeSessionFixture,
|
||||
sessions: [activeSessionFixture],
|
||||
filteredSessions: [activeSessionFixture],
|
||||
messages: [],
|
||||
isStreaming,
|
||||
sendMessage: (message, files) => {
|
||||
sendMessage(message, files);
|
||||
setIsStreaming(true);
|
||||
},
|
||||
stopStreaming,
|
||||
} satisfies UseChatReturn;
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Start then stop" } });
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" });
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(screen.getByTestId("chat-stop-btn"), { pointerType: "touch" });
|
||||
fireEvent.touchStart(screen.getByTestId("chat-stop-btn"));
|
||||
fireEvent.click(screen.getByTestId("chat-stop-btn"));
|
||||
});
|
||||
expect(stopStreaming).toHaveBeenCalledTimes(1);
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renders send button when not streaming", async () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
|
||||
Reference in New Issue
Block a user