feat(FN-3244): harden mobile FAB drag path and touch detection
Merges FN-3244's three-step hardening of the mobile FAB drag path, adding regression tests and finalizing TypeScript typing. Includes FN-3296's tokenized typography for agent health reasons, FN-3014's documented recovery semantics for the research system, and related CLI/dashboard work that improves Fusion-Task-Id: FN-3244
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 20px color-mix(in srgb, var(--todo) 35%, var(--bg));
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
z-index: 1000;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast), filter var(--transition-fast);
|
||||
}
|
||||
|
||||
@@ -386,9 +386,16 @@ function useDraggable(projectId?: string, externalDidDragRef?: React.MutableRefO
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ x: number; y: number; pointerX: number; pointerY: number } | null>(null);
|
||||
const positionRef = useRef(position);
|
||||
const activePointerIdRef = useRef<number | null>(null);
|
||||
const dragTargetRef = useRef<HTMLElement | null>(null);
|
||||
// Use external ref if provided, otherwise create internal one
|
||||
const didDragRef = externalDidDragRef ?? useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
positionRef.current = position;
|
||||
}, [position]);
|
||||
|
||||
// Clamp position to keep FAB within viewport
|
||||
const clampPosition = useCallback((pos: Position): Position => {
|
||||
if (typeof window === "undefined") return pos;
|
||||
@@ -422,90 +429,123 @@ function useDraggable(projectId?: string, externalDidDragRef?: React.MutableRefO
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Handle pointer down (start drag)
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
// Only handle primary button (left click) or touch
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||
|
||||
// Check if this is a click on an interactive element inside the FAB (not the FAB itself)
|
||||
const target = e.target as HTMLElement;
|
||||
const fabButton = target.closest(".quick-chat-fab") as HTMLElement | null;
|
||||
if (!fabButton) return;
|
||||
|
||||
e.preventDefault();
|
||||
// setPointerCapture may not exist in jsdom/tests
|
||||
if (typeof fabButton.setPointerCapture === "function") {
|
||||
fabButton.setPointerCapture(e.pointerId);
|
||||
const endDrag = useCallback(() => {
|
||||
if (!dragStartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragStartRef.current = {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
pointerX: e.clientX,
|
||||
pointerY: e.clientY,
|
||||
};
|
||||
didDragRef.current = false;
|
||||
setIsDragging(true);
|
||||
const dragTarget = dragTargetRef.current;
|
||||
const pointerId = activePointerIdRef.current;
|
||||
|
||||
// Prevent text selection during drag
|
||||
document.body.style.userSelect = "none";
|
||||
}, [position]);
|
||||
if (dragTarget && pointerId !== null && typeof dragTarget.releasePointerCapture === "function") {
|
||||
dragTarget.releasePointerCapture(pointerId);
|
||||
}
|
||||
|
||||
// Handle pointer move (during drag)
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragStartRef.current || !isDragging) return;
|
||||
dragStartRef.current = null;
|
||||
activePointerIdRef.current = null;
|
||||
dragTargetRef.current = null;
|
||||
setIsDragging(false);
|
||||
document.body.style.userSelect = "";
|
||||
|
||||
const deltaX = e.clientX - dragStartRef.current.pointerX;
|
||||
const deltaY = e.clientY - dragStartRef.current.pointerY;
|
||||
if (didDragRef.current) {
|
||||
savePosition(positionRef.current);
|
||||
}
|
||||
|
||||
document.removeEventListener("pointermove", handleDocumentPointerMove);
|
||||
document.removeEventListener("pointerup", handleDocumentPointerUp);
|
||||
document.removeEventListener("pointercancel", handleDocumentPointerCancel);
|
||||
}, [savePosition]);
|
||||
|
||||
const handleDocumentPointerMove = useCallback((event: PointerEvent) => {
|
||||
if (!dragStartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activePointerIdRef.current !== null && event.pointerId !== activePointerIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - dragStartRef.current.pointerX;
|
||||
const deltaY = event.clientY - dragStartRef.current.pointerY;
|
||||
|
||||
// Check if we've moved enough to be considered a drag (>= 5px)
|
||||
if (Math.abs(deltaX) >= 5 || Math.abs(deltaY) >= 5) {
|
||||
didDragRef.current = true;
|
||||
}
|
||||
|
||||
if (didDragRef.current) {
|
||||
// Move in the opposite direction (dragging right moves FAB right, which means reducing right offset)
|
||||
const newX = dragStartRef.current.x - deltaX;
|
||||
const newY = dragStartRef.current.y - deltaY;
|
||||
|
||||
const clamped = clampPosition({ x: newX, y: newY });
|
||||
setPosition(clamped);
|
||||
}
|
||||
}, [isDragging, clampPosition]);
|
||||
|
||||
// Handle pointer up (end drag)
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
|
||||
const fabButton = (e.target as HTMLElement).closest(".quick-chat-fab") as HTMLElement | null;
|
||||
if (fabButton && typeof fabButton.releasePointerCapture === "function") {
|
||||
fabButton.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
|
||||
// Restore text selection
|
||||
document.body.style.userSelect = "";
|
||||
|
||||
// If we didn't drag (movement < 5px), this was a click - caller handles toggle
|
||||
if (!didDragRef.current) {
|
||||
dragStartRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Save position to localStorage
|
||||
savePosition(position);
|
||||
// Move in the opposite direction (dragging right moves FAB right, which means reducing right offset)
|
||||
const newX = dragStartRef.current.x - deltaX;
|
||||
const newY = dragStartRef.current.y - deltaY;
|
||||
|
||||
dragStartRef.current = null;
|
||||
const clamped = clampPosition({ x: newX, y: newY });
|
||||
positionRef.current = clamped;
|
||||
setPosition(clamped);
|
||||
}, [clampPosition]);
|
||||
|
||||
const handleDocumentPointerUp = useCallback((event: PointerEvent) => {
|
||||
if (activePointerIdRef.current !== null && event.pointerId !== activePointerIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
endDrag();
|
||||
}, [endDrag]);
|
||||
|
||||
const handleDocumentPointerCancel = useCallback((event: PointerEvent) => {
|
||||
if (activePointerIdRef.current !== null && event.pointerId !== activePointerIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
endDrag();
|
||||
}, [endDrag]);
|
||||
|
||||
// Handle pointer down (start drag)
|
||||
const handlePointerDown = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
// Only handle primary button (left click) or touch
|
||||
if (event.button !== 0 && event.pointerType === "mouse") return;
|
||||
|
||||
const fabButton = event.currentTarget;
|
||||
|
||||
event.preventDefault();
|
||||
// setPointerCapture may not exist in jsdom/tests
|
||||
if (typeof fabButton.setPointerCapture === "function") {
|
||||
fabButton.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const currentPosition = positionRef.current;
|
||||
dragStartRef.current = {
|
||||
x: currentPosition.x,
|
||||
y: currentPosition.y,
|
||||
pointerX: event.clientX,
|
||||
pointerY: event.clientY,
|
||||
};
|
||||
activePointerIdRef.current = event.pointerId;
|
||||
dragTargetRef.current = fabButton;
|
||||
didDragRef.current = false;
|
||||
}, [position, savePosition]);
|
||||
setIsDragging(true);
|
||||
|
||||
// Prevent text selection during drag
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
document.addEventListener("pointermove", handleDocumentPointerMove, { passive: true });
|
||||
document.addEventListener("pointerup", handleDocumentPointerUp);
|
||||
document.addEventListener("pointercancel", handleDocumentPointerCancel);
|
||||
}, [handleDocumentPointerCancel, handleDocumentPointerMove, handleDocumentPointerUp]);
|
||||
|
||||
useEffect(() => () => {
|
||||
document.removeEventListener("pointermove", handleDocumentPointerMove);
|
||||
document.removeEventListener("pointerup", handleDocumentPointerUp);
|
||||
document.removeEventListener("pointercancel", handleDocumentPointerCancel);
|
||||
document.body.style.userSelect = "";
|
||||
}, [handleDocumentPointerCancel, handleDocumentPointerMove, handleDocumentPointerUp]);
|
||||
|
||||
return {
|
||||
position,
|
||||
isDragging,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -887,8 +927,6 @@ export function QuickChatFAB({
|
||||
position,
|
||||
isDragging,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
} = useDraggable(projectId, didDragRef);
|
||||
|
||||
// Panel stays 60px above FAB (FAB is 48px tall + 12px gap)
|
||||
@@ -1974,8 +2012,6 @@ export function QuickChatFAB({
|
||||
data-dragging={isDragging ? "true" : "false"}
|
||||
style={{ right: position.x, bottom: position.y }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onClick={handleFABClick}
|
||||
>
|
||||
<MessageSquare size={24} />
|
||||
|
||||
@@ -72,6 +72,9 @@ function createDeferredPromise<T>() {
|
||||
describe("QuickChatFAB session-first UX", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
localStorage.clear();
|
||||
mockUseAgents.mockReturnValue({ agents, activeAgents: agents, stats: null, isLoading: false, loadAgents: vi.fn(), loadStats: vi.fn() });
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session: modelSession });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
@@ -367,4 +370,39 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
expect(screen.getByTestId("quick-chat-waiting")).toHaveTextContent("Connecting…");
|
||||
expect(screen.queryByText("Loading conversation…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps tap behavior for below-threshold touch movement", async () => {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
|
||||
const fab = screen.getByTestId("quick-chat-fab");
|
||||
fireEvent.pointerDown(fab, { pointerId: 21, pointerType: "touch", button: 0, clientX: 120, clientY: 420 });
|
||||
fireEvent.pointerMove(document, { pointerId: 21, pointerType: "touch", clientX: 123, clientY: 423 });
|
||||
fireEvent.pointerUp(document, { pointerId: 21, pointerType: "touch", clientX: 123, clientY: 423 });
|
||||
fireEvent.click(fab);
|
||||
|
||||
expect(await screen.findByTestId("quick-chat-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("repositions on touch drag without opening panel and persists position", async () => {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
|
||||
const fab = screen.getByTestId("quick-chat-fab");
|
||||
fireEvent.pointerDown(fab, { pointerId: 33, pointerType: "touch", button: 0, clientX: 150, clientY: 500 });
|
||||
fireEvent.pointerMove(document, { pointerId: 33, pointerType: "touch", clientX: 180, clientY: 470 });
|
||||
fireEvent.pointerUp(document, { pointerId: 33, pointerType: "touch", clientX: 180, clientY: 470 });
|
||||
fireEvent.click(fab);
|
||||
|
||||
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
|
||||
|
||||
const saved = localStorage.getItem("fusion-quick-chat-position-proj-1");
|
||||
expect(saved).not.toBeNull();
|
||||
expect(saved).toContain("\"x\"");
|
||||
expect(saved).toContain("\"y\"");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user