diff --git a/.changeset/fn-8602-tablet-touch-resize.md b/.changeset/fn-8602-tablet-touch-resize.md
new file mode 100644
index 0000000000..993bb1c2ad
--- /dev/null
+++ b/.changeset/fn-8602-tablet-touch-resize.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Make task modal resize grips reliably usable on touch tablets.
+category: fix
+dev: Adds tablet-only touch targets and Chromium CDP hit-testing coverage.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 8591331d90..8e67f720e5 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -89,7 +89,7 @@ Movable dashboard pop-outs remember their last desktop location and size, while
### Task modal resizing on tablets
-Task Detail and New Task remain resizable on known touch tablets, including a 768px-wide tablet viewport. Task Detail exposes its accessible bottom-right resize grip; New Task keeps its draggable header and edge/corner resize controls. Their geometry stays within the viewport and is restored from browser storage on later tablet or desktop opens. True phones and narrow folded panes remain full-screen sheets without active resize controls so keyboard and safe-area behavior is unchanged.
+Task Detail and New Task remain resizable on known touch tablets, including a 768px-wide tablet viewport. Task Detail exposes its accessible bottom-right resize grip; New Task keeps its draggable header and edge/corner resize controls. On that tablet-touch surface, the painted control remains compact but its explicit resize hit target is at least 44px, sits outside the panel content, and owns touch gestures with pointer capture. Their geometry stays within the viewport and is restored from browser storage on later tablet or desktop opens. True phones, narrow folded panes, and desktop coarse-pointer devices do not receive the enlarged target: phones remain full-screen sheets and desktop preserves cursor-sized resize chrome.
## Mobile/PWA app icons
@@ -2293,3 +2293,7 @@ Dictation inserts a live partial transcript at the current caret (or replaces th
### Conversation tags
Direct conversations can be organized with reusable tags. Open a conversation's **More** menu to create a tag or toggle its assignments; a conversation can have multiple tags. Use the tag selector beside conversation search to filter pinned and recent conversations without affecting text search. Tags are project-scoped, and deleting a tag only removes its assignments—it never deletes conversations or messages. Chat Rooms do not use conversation tags.
+
+### Tablet touch modal resize
+
+Task Detail and New Task retain their desktop resize chrome, but tablet-class touch viewports expose a 44px `data-resize-hit-target` around resize controls. The target is enabled only by the shared tablet-touch viewport classifier; true-phone sheets and desktop coarse-pointer devices do not expose it.
diff --git a/docs/testing.md b/docs/testing.md
index e4e1056e23..be193e35f6 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -523,3 +523,7 @@ Use the exact heading `## Symptom Verification` and include all three required c
- [ ] **Assertion it is gone** — final verification reproduces the original failure condition and asserts it no longer occurs via a real automated test.
Symptom-based acceptance is mandatory for bug fixes: reproduce the original failure, prove it is gone, and keep the invariant covered across the `## Surface Enumeration` checklist. Green build/tests alone are insufficient when they do not exercise the reported symptom.
+
+### Chromium touch hit-testing
+
+Touch-resize regressions use the dashboard Vite fixture and Chromium CDP `Input.dispatchTouchEvent` start/move/end events. This is required where `elementFromPoint` and real touch hit testing matter; jsdom pointer dispatch does not provide layout hit testing.
diff --git a/packages/dashboard/app/components/NewTaskModal.css b/packages/dashboard/app/components/NewTaskModal.css
index cc93529d60..f46c593c52 100644
--- a/packages/dashboard/app/components/NewTaskModal.css
+++ b/packages/dashboard/app/components/NewTaskModal.css
@@ -131,6 +131,76 @@ visible tokenized focus ring so keyboard users can discover the same controls to
.new-task-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; }
.new-task-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; }
+/*
+FNXC:TaskModalResize 2026-07-26-10:40:
+Finger drags need a 44px hit area, unlike the mouse-sized painted edge/corner controls.
+These targets expand outward from the floating panel so they cannot cover its footer or
+scrollable body. The runtime tablet-touch class is required: true-phone sheets and desktop
+coarse-pointer devices must never expose an invisible resize target.
+*/
+.task-modal--touch-resize .new-task-resize-handle--touch-target {
+ z-index: 3;
+ touch-action: none;
+}
+
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--n,
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--s {
+ left: var(--modal-resize-touch-target);
+ right: var(--modal-resize-touch-target);
+ height: var(--modal-resize-touch-target);
+}
+
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--n {
+ top: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
+}
+
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--s {
+ bottom: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
+}
+
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--e,
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--w {
+ top: var(--modal-resize-touch-target);
+ bottom: var(--modal-resize-touch-target);
+ width: var(--modal-resize-touch-target);
+}
+
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--e {
+ right: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
+}
+
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--w {
+ left: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
+}
+
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--ne,
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--nw,
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--se,
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--sw {
+ width: var(--modal-resize-touch-target);
+ height: var(--modal-resize-touch-target);
+}
+
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--ne {
+ top: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
+ right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
+}
+
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--nw {
+ top: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
+ left: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
+}
+
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--se {
+ bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
+ right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
+}
+
+.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--sw {
+ bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
+ left: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
+}
+
.new-task-modal .modal-body {
padding: var(--space-xl);
overflow-y: auto;
diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx
index ebee450274..6403a5d259 100644
--- a/packages/dashboard/app/components/NewTaskModal.tsx
+++ b/packages/dashboard/app/components/NewTaskModal.tsx
@@ -28,7 +28,7 @@ import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
-import { useViewportMode } from "../hooks/useViewportMode";
+import { isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
@@ -412,6 +412,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const viewportMode = useViewportMode();
+ const isTabletTouchResize = isTabletTouchViewport(viewportMode);
useMobileScrollLock(isOpen);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: viewportMode === "mobile",
@@ -521,6 +522,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
+ moveEvent.preventDefault();
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const nextSize = clampFloatSize({
@@ -546,7 +548,15 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
- function handlePointerUp() {
+ /*
+ FNXC:TaskModalResize 2026-07-26-10:51:
+ A tablet finger resize owns exactly the pointer that started it. Ignore another
+ finger's terminal event so it cannot release capture or persist partial geometry;
+ this preserves the established floating-window gesture isolation.
+ */
+ function handlePointerUp(upEvent: PointerEvent) {
+ if (upEvent.pointerId !== pointerId) return;
+ upEvent.preventDefault();
if (frame) cancelAnimationFrame(frame);
persistSize(latestSize);
persistPosition(latestPosition, latestSize);
@@ -1206,7 +1216,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
style={isFloating ? { zIndex } : undefined}
>
(
(null);
const viewportMode = useViewportMode();
- useModalResizePersist(modalRef, true, "task-detail-modal-size");
+ const isTabletTouchResize = isTabletTouchViewport(viewportMode);
+ useModalResizePersist(modalRef, true, "task-detail-modal-size", { touchTargets: isTabletTouchResize });
useMobileScrollLock(true);
const overlayDismissProps = useOverlayDismiss(onClose);
/*
@@ -6614,7 +6615,7 @@ export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
aria-modal="true"
>
({
isShortViewport: () => false,
getViewportMode: () => mockViewportMode,
isMobileViewport: () => mockViewportMode === "mobile",
+ isTabletTouchViewport: () => mockViewportMode === "tablet",
useViewportMode: () => mockViewportMode,
}));
@@ -2193,6 +2194,7 @@ describe("NewTaskModal", () => {
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("role", "separator");
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("aria-label", "Resize new task window");
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("tabindex", "0");
+ expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("data-resize-hit-target", "true");
}
});
@@ -2253,6 +2255,8 @@ describe("NewTaskModal", () => {
fireEvent.pointerDown(handle, { pointerId: 4, clientX: 100, clientY: 100, pointerType: "touch" });
fireEvent.pointerMove(handle, { pointerId: 4, clientX: 140, clientY: 130, pointerType: "touch" });
+ fireEvent.pointerUp(handle, { pointerId: 9, clientX: 140, clientY: 130, pointerType: "touch" });
+ expect(document.body.style.userSelect).toBe("none");
fireEvent.pointerUp(handle, { pointerId: 4, clientX: 140, clientY: 130, pointerType: "touch" });
expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(initialWidth);
diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx
index e08f3170c6..13b252b032 100644
--- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx
@@ -874,6 +874,7 @@ describe("TaskDetailModal", () => {
const grip = modal?.querySelector(".modal-resize-grip") as HTMLElement;
expect(grip).toHaveAttribute("aria-label", "Resize modal from bottom-right corner");
expect(grip).toHaveAttribute("tabindex", "0");
+ expect(grip).toHaveAttribute("data-resize-hit-target", "true");
// FNXC:TaskModalResize 2026-07-24-19:20: The 768px tablet recovery must
// remain keyboard discoverable, not merely restore a touch-only grip.
diff --git a/packages/dashboard/app/hooks/__tests__/useModalResizePersist.test.tsx b/packages/dashboard/app/hooks/__tests__/useModalResizePersist.test.tsx
index 06a9747ced..974e2fd7fc 100644
--- a/packages/dashboard/app/hooks/__tests__/useModalResizePersist.test.tsx
+++ b/packages/dashboard/app/hooks/__tests__/useModalResizePersist.test.tsx
@@ -99,14 +99,16 @@ function Harness({
initialWidth,
isOpen = true,
storageKey = STORAGE_KEY,
+ touchTargets = false,
}: {
initialHeight?: string;
initialWidth?: string;
isOpen?: boolean;
storageKey?: string;
+ touchTargets?: boolean;
}) {
const modalRef = useRef(null);
- useModalResizePersist(modalRef, isOpen, storageKey);
+ useModalResizePersist(modalRef, isOpen, storageKey, { touchTargets });
return (
{
document.body.style.userSelect = "";
});
- it("injects a touch-capable resize grip on tablet and persists dragged size", () => {
+ it("injects a tablet touch hit target and persists a touch drag", () => {
setViewport(900);
- render(
);
+ render(
);
const modal = screen.getByTestId("modal");
installModalGeometry(modal);
@@ -146,6 +148,8 @@ describe("useModalResizePersist", () => {
expect(grip).toHaveAttribute("role", "separator");
expect(grip).toHaveAttribute("aria-label", "Resize modal from bottom-right corner");
+ expect(grip).toHaveAttribute("data-resize-hit-target", "true");
+ expect(grip).toHaveClass("modal-resize-grip--touch-target");
dispatchPointerEvent(grip, "pointerdown", { clientX: 10, clientY: 20, pointerType: "touch" });
dispatchPointerEvent(document, "pointermove", { clientX: 70, clientY: 65, pointerType: "touch" });
diff --git a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts
index fe00ab5024..1640da5873 100644
--- a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts
@@ -1,6 +1,6 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { getViewportMode, isFullScreenSheetViewport, isMobileViewport, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
+import { getViewportMode, isFullScreenSheetViewport, isMobileViewport, isTabletTouchViewport, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)";
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
@@ -242,6 +242,30 @@ describe("useViewportMode", () => {
});
});
+ it("enables the resize touch target only for tablet-class touch viewports", () => {
+ const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
+ try {
+ stubScreen(768, 1024);
+ installViewportMedia({ width: true, height: false, tablet: false });
+ expect(isTabletTouchViewport()).toBe(true);
+
+ stubScreen(1024, 768);
+ installViewportMedia({ width: false, height: false, tablet: true });
+ expect(isTabletTouchViewport()).toBe(true);
+
+ stubScreen(1920, 1080);
+ installViewportMedia({ width: false, height: false, tablet: false });
+ expect(isTabletTouchViewport()).toBe(false);
+
+ stubScreen(390, 844);
+ installViewportMedia({ width: true, height: false, tablet: false });
+ expect(isTabletTouchViewport()).toBe(false);
+ } finally {
+ if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints);
+ }
+ });
+
it("keeps a touch tablet at the 768px boundary out of the phone presentation", () => {
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
stubScreen(768, 1024);
diff --git a/packages/dashboard/app/hooks/useModalResizePersist.ts b/packages/dashboard/app/hooks/useModalResizePersist.ts
index edaec4196c..4680893597 100644
--- a/packages/dashboard/app/hooks/useModalResizePersist.ts
+++ b/packages/dashboard/app/hooks/useModalResizePersist.ts
@@ -10,6 +10,11 @@ interface PersistedSize {
const RESIZE_GRIP_CLASS = "modal-resize-grip";
const RESIZE_GRIP_LABEL = "Resize modal from bottom-right corner";
+interface ModalResizePersistOptions {
+ /** Enable the explicit 44px hit target on the tablet-touch task-detail surface. */
+ touchTargets?: boolean;
+}
+
/*
FNXC:TaskModalResize 2026-07-24-19:20:
The shared Task Detail grip must offer the same keyboard discovery as floating task windows.
@@ -51,11 +56,13 @@ function readPersistableSize(node: HTMLElement): PersistedSize {
* @param ref ref to the resizable modal element
* @param isOpen the modal's open flag — observation only runs while true
* @param storageKey localStorage key, must be stable + unique per modal
+ * @param options tablet-only touch-target opt-in; other shared modal consumers retain desktop geometry
*/
export function useModalResizePersist(
ref: RefObject
,
isOpen: boolean,
storageKey: string,
+ options: ModalResizePersistOptions = {},
): void {
useEffect(() => {
if (!isOpen) return;
@@ -104,7 +111,16 @@ export function useModalResizePersist(
};
const grip = document.createElement("div");
- grip.className = RESIZE_GRIP_CLASS;
+ grip.className = `${RESIZE_GRIP_CLASS}${options.touchTargets ? " modal-resize-grip--touch-target" : ""}`;
+ if (options.touchTargets) {
+ /*
+ FNXC:TaskModalResize 2026-07-26-10:40:
+ Keep the painted corner grip mouse-sized while exposing a separately queryable
+ touch hit target. This lets tablet touch input land outside the legacy visual
+ corner without enlarging borders, footer chrome, or content hit areas.
+ */
+ grip.dataset.resizeHitTarget = "true";
+ }
grip.setAttribute("role", "separator");
grip.setAttribute("aria-label", RESIZE_GRIP_LABEL);
grip.setAttribute("aria-orientation", "vertical");
@@ -165,9 +181,10 @@ export function useModalResizePersist(
document.body.style.userSelect = "none";
const onPointerMove = (moveEvent: PointerEvent) => {
+ if (moveEvent.pointerId !== event.pointerId) return;
moveEvent.preventDefault();
- const nextWidth = startWidth + moveEvent.clientX - startX;
- const nextHeight = startHeight + moveEvent.clientY - startY;
+ const nextWidth = Math.min(window.innerWidth, startWidth + moveEvent.clientX - startX);
+ const nextHeight = Math.min(window.innerHeight, startHeight + moveEvent.clientY - startY);
if (nextWidth > 0) node.style.width = `${nextWidth}px`;
if (nextHeight > 0) node.style.height = `${nextHeight}px`;
syncGripAria();
@@ -175,6 +192,7 @@ export function useModalResizePersist(
};
const endDrag = (upEvent: PointerEvent) => {
+ if (upEvent.pointerId !== event.pointerId) return;
if (typeof grip.releasePointerCapture === "function") {
grip.releasePointerCapture(upEvent.pointerId);
}
@@ -227,5 +245,5 @@ export function useModalResizePersist(
observer?.disconnect();
if (saveTimer) clearTimeout(saveTimer);
};
- }, [ref, isOpen, storageKey]);
+ }, [ref, isOpen, storageKey, options.touchTargets]);
}
diff --git a/packages/dashboard/app/hooks/useViewportMode.ts b/packages/dashboard/app/hooks/useViewportMode.ts
index 415885a354..43ad02b187 100644
--- a/packages/dashboard/app/hooks/useViewportMode.ts
+++ b/packages/dashboard/app/hooks/useViewportMode.ts
@@ -134,6 +134,19 @@ export function getViewportMode(): ViewportMode {
return "desktop";
}
+/**
+ * Whether a viewport is the tablet-only touch-resize surface.
+ *
+ * FNXC:TaskModalResize 2026-07-26-10:40:
+ * Tablet resize controls need a finger-sized target, but `(pointer: coarse)` alone
+ * would also enlarge controls on desktop hybrids and true phones. Compose the existing
+ * physical-screen-aware tablet classifier with touch capability instead; phones retain
+ * full-screen sheets and desktop coarse-pointer devices retain mouse-sized chrome.
+ */
+export function isTabletTouchViewport(mode = getViewportMode()): boolean {
+ return mode === "tablet" && hasTouchScreen() && !isPhoneClassScreen();
+}
+
export function useViewportMode(): ViewportMode {
const [mode, setMode] = useState(getViewportMode);
diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css
index b6dd1d90c5..d115f14392 100644
--- a/packages/dashboard/app/styles.css
+++ b/packages/dashboard/app/styles.css
@@ -152,6 +152,14 @@ html {
44px accessibility floor without embedding a raw dimension in component-specific rules.
*/
--touch-target-min-size: 44px;
+ /*
+ FNXC:TaskModalResize 2026-07-26-10:40:
+ Task-modal resize targets use the accessibility floor as an explicit hit-area
+ contract. The painted grip remains compact; only the tablet-class touch guard
+ opts into this token so phones and desktop coarse-pointer layouts do not gain
+ invisible resize chrome.
+ */
+ --modal-resize-touch-target: var(--touch-target-min-size);
/*
FNXC:QuickAddActionRow 2026-07-16-14:00:
@@ -1344,6 +1352,14 @@ Modal and dock pop-out surfaces should not darken or blur the app behind them. K
background: transparent;
}
+.modal-resize-grip--touch-target {
+ width: var(--modal-resize-touch-target);
+ height: var(--modal-resize-touch-target);
+ right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
+ bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
+ z-index: 3;
+}
+
.modal-resize-grip::after {
content: "";
position: absolute;
diff --git a/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.html b/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.html
new file mode 100644
index 0000000000..2db9a08430
--- /dev/null
+++ b/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.html
@@ -0,0 +1,5 @@
+
+
+ Task modal touch resize E2E
+
+
diff --git a/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx b/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx
new file mode 100644
index 0000000000..7c886383ad
--- /dev/null
+++ b/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx
@@ -0,0 +1,60 @@
+import React, { useRef } from "react";
+import { createRoot } from "react-dom/client";
+import i18n from "i18next";
+import { I18nextProvider, initReactI18next } from "react-i18next";
+import "./styles.css";
+import "./components/TaskDetailModal.css";
+import { useModalResizePersist } from "./hooks/useModalResizePersist";
+import { isTabletTouchViewport, useViewportMode } from "./hooks/useViewportMode";
+import { NewTaskModal } from "./components/NewTaskModal";
+import { ConfirmDialogProvider } from "./hooks/useConfirm";
+
+const params = new URLSearchParams(window.location.search);
+const surface = params.get("surface") ?? "new-task";
+if (params.has("reset")) localStorage.clear();
+
+void i18n.use(initReactI18next).init({
+ lng: "en",
+ fallbackLng: "en",
+ resources: { en: { app: {} } },
+ interpolation: { escapeValue: false },
+});
+
+// Browser fixtures provide the production form's minimal typed API payloads. The resize assertions
+// exercise NewTaskModal itself rather than an API-dependent form failure.
+window.fetch = async (input) => {
+ const url = String(input);
+ const payload = url.includes("/models")
+ ? { models: [], favoriteProviders: [], favoriteModels: [] }
+ : url.includes("/settings") ? {}
+ : [];
+ return new Response(JSON.stringify(payload), { headers: { "content-type": "application/json" } });
+};
+
+function TaskDetailResizeHarness() {
+ const ref = useRef(null);
+ const viewportMode = useViewportMode();
+ const touchTargets = isTabletTouchViewport(viewportMode);
+ useModalResizePersist(ref, true, "task-detail-modal-size", { touchTargets });
+ return
+
+
Task detail
Task detail body
+
+
;
+}
+
+function Fixture() {
+ return
+
+ {surface === "task-detail" ? : undefined}
+ onCreateTask={async () => ({ id: "FN-E2E" }) as never}
+ addToast={() => undefined}
+ />}
+
+ ;
+}
+
+createRoot(document.getElementById("root")!).render();
diff --git a/packages/dashboard/e2e/__screenshots__/fn-8602/phone-fullscreen.png b/packages/dashboard/e2e/__screenshots__/fn-8602/phone-fullscreen.png
new file mode 100644
index 0000000000..519bdd7276
Binary files /dev/null and b/packages/dashboard/e2e/__screenshots__/fn-8602/phone-fullscreen.png differ
diff --git a/packages/dashboard/e2e/__screenshots__/fn-8602/tablet-after.png b/packages/dashboard/e2e/__screenshots__/fn-8602/tablet-after.png
new file mode 100644
index 0000000000..fd486c8589
Binary files /dev/null and b/packages/dashboard/e2e/__screenshots__/fn-8602/tablet-after.png differ
diff --git a/packages/dashboard/e2e/__screenshots__/fn-8602/tablet-before.png b/packages/dashboard/e2e/__screenshots__/fn-8602/tablet-before.png
new file mode 100644
index 0000000000..1976885368
Binary files /dev/null and b/packages/dashboard/e2e/__screenshots__/fn-8602/tablet-before.png differ
diff --git a/packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts b/packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts
new file mode 100644
index 0000000000..ab6f4258e9
--- /dev/null
+++ b/packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts
@@ -0,0 +1,154 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { createServer, type ViteDevServer } from "vite";
+import { createRequire } from "node:module";
+import { existsSync } from "node:fs";
+import { mkdir } from "node:fs/promises";
+import path from "node:path";
+
+const requireFromEngine = createRequire(new URL("../../../engine/package.json", import.meta.url));
+const { chromium } = requireFromEngine("playwright-core") as { chromium: { launch(options: { executablePath: string; headless: boolean; args?: string[] }): Promise } };
+type Browser = { newPage(options: { viewport: { width: number; height: number } }): Promise; close(): Promise };
+type Page = { goto(url: string): Promise; evaluate(fn: (arg: Arg) => T, arg?: Arg): Promise; locator(selector: string): Locator; waitForTimeout(ms: number): Promise; screenshot(options: { path: string }): Promise; close(): Promise; context(): { newCDPSession(page: Page): Promise }; on(event: "console" | "pageerror", listener: (message: { text?(): string; message?: string }) => void): void };
+type Locator = { boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null> };
+type Cdp = { send(method: string, params: Record): Promise };
+type Point = { x: number; y: number };
+type Rect = { x: number; y: number; width: number; height: number };
+
+const browserCandidates = process.platform === "darwin"
+ ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"]
+ : ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
+const executablePath = [process.env.FUSION_BROWSER_SMOKE_BROWSER, process.env.CHROME_BIN, ...browserCandidates].find((candidate): candidate is string => Boolean(candidate) && existsSync(candidate));
+const screenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-8602");
+
+async function touchDrag(cdp: Cdp, point: Point, delta = { x: 48, y: 36 }) {
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: point.x, y: point.y, id: 1 }] });
+ for (const fraction of [0.25, 0.5, 0.75, 1]) {
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: point.x + delta.x * fraction, y: point.y + delta.y * fraction, id: 1 }] });
+ }
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
+}
+
+async function setTabletMetrics(cdp: Cdp, width: number, height: number) {
+ await cdp.send("Emulation.setDeviceMetricsOverride", {
+ width,
+ height,
+ screenWidth: width,
+ screenHeight: height,
+ deviceScaleFactor: 1,
+ mobile: false,
+ });
+ await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
+}
+
+async function rect(page: Page, selector: string): Promise {
+ return page.evaluate((target) => {
+ const panel = document.querySelector(target);
+ if (!panel) throw new Error(`Missing ${target}`);
+ const { x, y, width, height } = panel.getBoundingClientRect();
+ return { x, y, width, height };
+ }, selector);
+}
+
+async function targetCenter(page: Page, selector: string): Promise {
+ const box = await page.locator(selector).boundingBox();
+ if (!box) throw new Error(`Missing resize target ${selector}`);
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
+}
+
+/*
+FNXC:TaskModalResize 2026-08-12-15:12:
+Browser CDP gestures are required because jsdom cannot resolve CSS hit targets. This fixture mounts
+both production resize paths and sends CSS-pixel touch input through Chromium so elementFromPoint,
+pointer capture, persistence, and header-drag isolation use the same browser input path.
+*/
+describe.runIf(executablePath)("Task modal tablet touch resize browser regression", () => {
+ let server: ViteDevServer; let browser: Browser; let baseUrl = "";
+ beforeAll(async () => {
+ server = await createServer({ root: process.cwd(), server: { host: "127.0.0.1", port: 0, watch: null }, logLevel: "error" });
+ await server.listen(); baseUrl = server.resolvedUrls?.local[0] ?? "";
+ browser = await chromium.launch({ executablePath, headless: true, ...(process.env.CI ? { args: ["--no-sandbox", "--disable-dev-shm-usage"] } : {}) });
+ }, 30_000);
+ afterAll(async () => {
+ await browser?.close();
+ await server?.watcher.close();
+ server?.ws.close();
+ server?.httpServer?.closeAllConnections?.();
+ await new Promise((resolve) => server?.httpServer?.close(() => resolve()));
+ await server?.pluginContainer.close();
+ }, 15_000);
+
+ for (const [width, height] of [[768, 1024], [820, 1180]] as const) {
+ it(`hits and resizes Task Detail and New Task at the ${width}px tablet boundary with CDP touch`, async () => {
+ const page = await browser.newPage({ viewport: { width, height } });
+ const cdp = await page.context().newCDPSession(page);
+ await setTabletMetrics(cdp, width, height);
+ page.on("console", (message) => console.log(`[task-modal-touch-resize] ${message.text?.() ?? ""}`));
+ page.on("pageerror", (message) => console.error(`[task-modal-touch-resize] ${message.message ?? ""}`));
+ await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=task-detail&reset=1`);
+ await page.waitForTimeout(350);
+ expect(await page.evaluate(() => window.scrollX === 0 && window.scrollY === 0)).toBe(true);
+ expect(await page.evaluate(() => document.querySelectorAll("[data-resize-hit-target='true']").length)).toBe(1);
+ await mkdir(screenshots, { recursive: true });
+ if (width === 820) await page.screenshot({ path: path.join(screenshots, "tablet-before.png") });
+
+ const detailSelector = "[data-testid='task-detail-modal'] .modal-resize-grip";
+ const detailPoint = await targetCenter(page, detailSelector);
+ expect(await page.evaluate((point) => document.elementFromPoint(point.x, point.y)?.getAttribute("data-resize-hit-target"), detailPoint)).toBe("true");
+ const detailBefore = await rect(page, "[data-testid='task-detail-modal']");
+ await touchDrag(cdp, detailPoint);
+ await page.waitForTimeout(250);
+ const detailAfter = await rect(page, "[data-testid='task-detail-modal']");
+ expect(detailAfter.width).toBeGreaterThan(detailBefore.width);
+ expect(detailAfter.height).toBeGreaterThan(detailBefore.height);
+ expect(detailAfter.width).toBeLessThanOrEqual(width);
+ expect(detailAfter.height).toBeLessThanOrEqual(height);
+ expect(await page.evaluate(() => localStorage.getItem("task-detail-modal-size"))).not.toBeNull();
+
+ await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=new-task`);
+ await page.waitForTimeout(350);
+ expect(await page.evaluate(() => document.querySelectorAll("[data-resize-hit-target='true']").length)).toBe(8);
+ const newTaskPanel = ".new-task-modal";
+ const headerPoint = await targetCenter(page, "[data-testid='new-task-drag-handle']");
+ const newTaskBeforeHeaderDrag = await rect(page, newTaskPanel);
+ await touchDrag(cdp, headerPoint, { x: 32, y: 28 });
+ await page.waitForTimeout(100);
+ const newTaskAfterHeaderDrag = await rect(page, newTaskPanel);
+ expect(newTaskAfterHeaderDrag.x).not.toBe(newTaskBeforeHeaderDrag.x);
+ expect(newTaskAfterHeaderDrag.y).not.toBe(newTaskBeforeHeaderDrag.y);
+ expect(newTaskAfterHeaderDrag.width).toBe(newTaskBeforeHeaderDrag.width);
+ expect(newTaskAfterHeaderDrag.height).toBe(newTaskBeforeHeaderDrag.height);
+
+ const newTaskTarget = "[data-testid='new-task-resize-se']";
+ const newTaskPoint = await targetCenter(page, newTaskTarget);
+ expect(await page.evaluate((point) => document.elementFromPoint(point.x, point.y)?.getAttribute("data-resize-hit-target"), newTaskPoint)).toBe("true");
+ const newTaskBeforeResize = await rect(page, newTaskPanel);
+ await touchDrag(cdp, newTaskPoint);
+ await page.waitForTimeout(100);
+ const newTaskAfterResize = await rect(page, newTaskPanel);
+ expect(newTaskAfterResize.width).toBeGreaterThan(newTaskBeforeResize.width);
+ expect(newTaskAfterResize.height).toBeGreaterThan(newTaskBeforeResize.height);
+ expect(newTaskAfterResize.width).toBeLessThanOrEqual(width - 32);
+ expect(newTaskAfterResize.height).toBeLessThanOrEqual(height - 32);
+ expect(await page.evaluate(() => localStorage.getItem("fusion:new-task-modal-size"))).not.toBeNull();
+ expect(await page.evaluate(() => localStorage.getItem("fusion:new-task-modal-position"))).not.toBeNull();
+ if (width === 820) await page.screenshot({ path: path.join(screenshots, "tablet-after.png") });
+ await page.close();
+ }, 30_000);
+ }
+
+ it("keeps the true-phone sheet free of active resize targets", async () => {
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, screenWidth: 390, screenHeight: 844, deviceScaleFactor: 1, mobile: true });
+ await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
+ await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?reset=1`);
+ await page.waitForTimeout(250);
+ expect(await page.evaluate(() => document.querySelector("[data-resize-hit-target]") === null)).toBe(true);
+ expect(await page.evaluate(() => {
+ const panel = document.querySelector(".new-task-modal");
+ return panel ? panel.getBoundingClientRect().height >= window.innerHeight * 0.9 : false;
+ })).toBe(true);
+ await page.screenshot({ path: path.join(screenshots, "phone-fullscreen.png") });
+ await page.close();
+ }, 30_000);
+});