FN-7922: fix jerky tablet drag on headerless floating-window handles

Ensures every draggable modal surface (terminal, artifacts viewer, and other
FloatingWindow delegates) keeps touch-action: none on its drag handle so
tablet touch-drag is captured by FloatingWindow's pointermove stream instead
of being intersected by page pan, and backs the contract with tests.

- Add touch-action: none to .artifacts-gallery-viewer-header, the headerless
  FloatingWindow's delegated drag handle, matching the other movable modal
  handles.
- Add a FloatingWindow test asserting touch-action: none is present across
  every tablet movable-modal drag handle (terminal, artifacts, workflow
  editor, automation, mission interview, PR create, file browser, right
  dock, new task modal, quick chat FAB) and absent from broad tablet
  pan-y overrides.
- Add a FloatingWindow test exercising the captured tablet touch-drag path
  for a headerless delegated handle (pointerdown/move/up with
  pointerType: "touch").
- Fix TerminalModal drag test to send pointerType: "touch" on
  pointerdown/move/up so it exercises the same tablet touch-drag contract.
- Add changeset for the tablet drag fix.

Files changed:
 .changeset/fn-7922-tablet-modal-drag.md            |  7 ++
 .../dashboard/app/components/ArtifactsGallery.css  |  5 ++
 .../components/__tests__/FloatingWindow.test.tsx   | 76 +++++++++++++++++++++-
 .../components/__tests__/TerminalModal.test.tsx    |  6 +-
 4 files changed, 90 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-7922

Fusion-Task-Lineage: 0d63ced5-faa7-4362-a42d-884b4c889ebf

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 22:59:52 -07:00
parent 313956df5a
commit 87aab438dc
4 changed files with 90 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix jerky tablet drag for the terminal and other movable modals.
category: fix
dev: Reassert drag-handle `touch-action: none` coverage for headerless floating-window delegates and test the tablet touch-drag contract across movable modal surfaces.

View File

@@ -369,11 +369,16 @@ Viewers live inside the shared FloatingWindow (draggable by the viewer header, r
padding: var(--space-md); padding: var(--space-md);
} }
/*
FNXC:ArtifactsGallery 2026-07-12-21:10:
The artifacts viewer is a headerless FloatingWindow, so this header is the delegated drag handle on tablet and coarse-pointer floating layouts. Keep its effective `touch-action: none` so a single-finger drag is delivered through FloatingWindow's captured pointermove stream instead of being intersected back into page pan; the mobile sheet override below still disables the desktop drag affordance at <=768px.
*/
.artifacts-gallery-viewer-header { .artifacts-gallery-viewer-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
cursor: grab; cursor: grab;
touch-action: none;
} }
.artifacts-gallery-viewer-header:active { .artifacts-gallery-viewer-header:active {

View File

@@ -1,11 +1,12 @@
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture"; import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture";
import { FloatingWindow } from "../FloatingWindow"; import { FloatingWindow } from "../FloatingWindow";
const floatingWindowCss = readFileSync("app/components/FloatingWindow.css", "utf8"); const floatingWindowCss = readFileSync("app/components/FloatingWindow.css", "utf8");
const allAppCss = loadAllAppCss(); const allAppCss = loadAllAppCss();
const stylesCss = loadStylesCss();
function cssRuleFor(css: string, selector: string): string { function cssRuleFor(css: string, selector: string): string {
const start = css.indexOf(`${selector} {`); const start = css.indexOf(`${selector} {`);
@@ -14,6 +15,15 @@ function cssRuleFor(css: string, selector: string): string {
return css.slice(start, end); return css.slice(start, end);
} }
function cssRuleContaining(css: string, selector: string, declaration: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\ /g, "\\s+");
const matches = css.matchAll(new RegExp(`${escaped}\\s*\\{[^}]*\\}`, "g"));
for (const match of matches) {
if (match[0].includes(declaration)) return match[0];
}
return "";
}
/* /*
FNXC:FloatingWindow 2026-06-22-20:45: FNXC:FloatingWindow 2026-06-22-20:45:
Contract tests for the reusable non-blocking floating window: Contract tests for the reusable non-blocking floating window:
@@ -83,6 +93,35 @@ describe("FloatingWindow", () => {
} }
}); });
it("keeps every tablet movable-modal drag handle on the explicit touch-action none contract", () => {
const tabletStylesStart = stylesCss.indexOf("@media (min-width: 769px) and (max-width: 1024px)");
const mobileStylesStart = stylesCss.indexOf("@media (max-width: 768px)", tabletStylesStart);
expect(tabletStylesStart).toBeGreaterThan(-1);
expect(mobileStylesStart).toBeGreaterThan(tabletStylesStart);
const tabletBlock = stylesCss.slice(tabletStylesStart, mobileStylesStart);
expect(tabletBlock).not.toContain("* {");
expect(tabletBlock).not.toContain("touch-action: pan-y;");
for (const selector of [
".floating-window__header",
".floating-window--headerless .task-detail-content--embedded > .modal-header",
".chat-view--floating .view-header",
".floating-window--workflow-editor .wf-editor-header",
".floating-window--automation .automation-modal__drag-handle",
".floating-window--mission-interview .mission-interview-modal__drag-handle",
".floating-window--pr-create .pr-create-modal__drag-handle",
".file-browser-modal-header",
".artifacts-gallery-viewer-header",
".terminal-header--draggable",
".right-dock-expand-modal__header--draggable",
".new-task-modal__header--draggable",
".quick-chat-fab",
]) {
expect(cssRuleContaining(allAppCss, selector, "touch-action: none;"), selector).toContain("touch-action: none;");
}
});
it("moves a visible-header window through the captured touch drag path", () => { it("moves a visible-header window through the captured touch drag path", () => {
render( render(
<FloatingWindow <FloatingWindow
@@ -141,6 +180,41 @@ describe("FloatingWindow", () => {
} }
}); });
it("moves a headerless delegated handle through the captured tablet touch drag path", () => {
render(
<FloatingWindow
windowKey="artifacts-delegate"
title="Artifacts"
onClose={() => {}}
hideHeader
dragHandleSelector=".artifacts-gallery-viewer-header"
className="artifacts-gallery-window"
defaultSize={{ width: 320, height: 240 }}
defaultPosition={{ x: 90, y: 110 }}
minSize={{ width: 240, height: 180 }}
>
<div className="artifacts-gallery-viewer-header">Artifacts header</div>
<div aria-label="empty artifacts body" />
</FloatingWindow>
);
const panel = screen.getByTestId("floating-window-artifacts-delegate");
const delegatedHeader = screen.getByText("Artifacts header");
const setPointerCapture = vi.fn();
const releasePointerCapture = vi.fn();
Object.defineProperty(panel, "setPointerCapture", { configurable: true, value: setPointerCapture });
Object.defineProperty(panel, "releasePointerCapture", { configurable: true, value: releasePointerCapture });
fireEvent.pointerDown(delegatedHeader, { pointerId: 23, pointerType: "touch", clientX: 120, clientY: 140 });
fireEvent.pointerMove(panel, { pointerId: 23, pointerType: "touch", clientX: 150, clientY: 170 });
fireEvent.pointerUp(panel, { pointerId: 23, pointerType: "touch", clientX: 150, clientY: 170 });
expect(setPointerCapture).toHaveBeenCalledWith(23);
expect(releasePointerCapture).toHaveBeenCalledWith(23);
expect(panel.style.left).toBe("120px");
expect(panel.style.top).toBe("140px");
});
it("scopes mobile sheet sizing and hidden resize handles to task-detail pop-outs", () => { it("scopes mobile sheet sizing and hidden resize handles to task-detail pop-outs", () => {
expect(floatingWindowCss).toContain("FNXC:MobileTaskPopups 2026-06-29-00:00"); expect(floatingWindowCss).toContain("FNXC:MobileTaskPopups 2026-06-29-00:00");
expect(floatingWindowCss).toContain(".floating-window--task-detail {"); expect(floatingWindowCss).toContain(".floating-window--task-detail {");

View File

@@ -1089,9 +1089,9 @@ describe("TerminalModal", () => {
const header = modal.querySelector(".terminal-header") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void }; const header = modal.querySelector(".terminal-header") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void };
header.setPointerCapture = vi.fn(); header.setPointerCapture = vi.fn();
header.releasePointerCapture = vi.fn(); header.releasePointerCapture = vi.fn();
fireEvent.pointerDown(header, { pointerId: 2, clientX: 100, clientY: 100 }); fireEvent.pointerDown(header, { pointerId: 2, pointerType: "touch", clientX: 100, clientY: 100 });
fireEvent.pointerMove(header, { pointerId: 2, clientX: 125, clientY: 135 }); fireEvent.pointerMove(header, { pointerId: 2, pointerType: "touch", clientX: 125, clientY: 135 });
fireEvent.pointerUp(header, { pointerId: 2 }); fireEvent.pointerUp(header, { pointerId: 2, pointerType: "touch" });
await waitFor(() => { await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBeTruthy(); expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBeTruthy();