feat(FN-1137): improve mobile dropdown and file browser interactions

- Add viewport-aware positioning/clamping for Quick Scripts and Quick Entry dropdown menus
- Implement long-press context menu behavior in FileBrowser with iOS momentum scrolling support
- Expand dashboard CSS and component logic to stabilize mobile overlay behavior and remove unused inline WebKit style
- Add focused tests for FileBrowser, QuickScriptsDropdown, and mobile dropdown positioning plus README notes
This commit is contained in:
gsxdsm
2026-04-08 05:59:12 -07:00
parent a7eb95909a
commit 586d9b33b1
8 changed files with 888 additions and 78 deletions

View File

@@ -132,6 +132,25 @@ The dashboard stylesheet defines a shared mobile foundation in `app/styles.css`
- Text-entry controls (`input`, `select`, `textarea`) use **16px font-size** on mobile to prevent iOS Safari auto-zoom.
- **Safe-area pattern (notched devices / Capacitor webview):** use `env(safe-area-inset-top|right|bottom|left, 0px)` for root/layout containers (for example `#root`, `.header`, `.modal`, `.board`) so content avoids status bars and home indicators.
### Mobile Dropdown & Touch
Mobile dropdown behavior follows a consistent viewport-aware anchoring pattern so menus stay usable in narrow viewports and virtual-keyboard scenarios.
- **Portal dropdown positioning pattern** (QuickEntry model menu, refine menu, QuickScripts):
- Compute trigger coordinates with `getBoundingClientRect()`.
- Resolve viewport dimensions using `window.visualViewport` when available (fallback to `window.innerWidth/innerHeight`).
- Compare available space above vs. below the trigger and open upward when space below is insufficient.
- Clamp horizontal placement to viewport padding and clamp menu height to available space.
- Recalculate while open on `resize`, capture-phase `scroll`, and `visualViewport` `resize`/`scroll`.
- **FileBrowser touch context menu pattern**:
- Keep desktop/right-click support via `onContextMenu`.
- Add long-press for touch (`500ms`) with a separate early feedback timer (`200ms`) that applies `.file-node--long-pressing`.
- Cancel long-press on touch move beyond a 10px threshold or on touch end/cancel.
- Guard click-through after long-press so context-menu opening does not also trigger file selection/navigation.
- Clamp context menu placement using visual viewport offsets for virtual-keyboard-safe positioning.
- **Momentum scrolling convention**:
- For scrollable dropdown lists and modal content containers, apply `-webkit-overflow-scrolling: touch` to preserve iOS momentum scrolling.
- Use base selectors for reusable scroll lists (for example: `.dep-dropdown`, `.model-combobox-list`, `.quick-scripts-dropdown__list`, `.file-browser-list`) and reinforce modal surfaces in the main mobile media query (`@media (max-width: 768px)`).
### Executor Status Bar
A persistent footer status bar at the bottom of the dashboard displays real-time executor statistics in project view. The status bar provides immediate visibility into the engine's state without opening modals or hovering over badges.

View File

@@ -65,6 +65,15 @@ interface DialogState {
const INITIAL_DIALOG: DialogState = { type: null, entry: null, entryFullPath: "" };
const LONG_PRESS_FEEDBACK_MS = 200;
const LONG_PRESS_DURATION_MS = 500;
const TOUCH_MOVE_THRESHOLD = 10;
interface TouchPoint {
x: number;
y: number;
}
// ── Context Menu Component ──────────────────────────────────────────────
interface ContextMenuItem {
@@ -90,19 +99,29 @@ function FileContextMenu({ x, y, entry, onAction, onClose }: FileContextMenuProp
useEffect(() => {
const menu = menuRef.current;
if (!menu) return;
const rect = menu.getBoundingClientRect();
const vv = window.visualViewport;
const viewportWidth = vv?.width && vv.width > 0 ? vv.width : window.innerWidth;
const viewportHeight = vv?.height && vv.height > 0 ? vv.height : window.innerHeight;
const offsetLeft = vv?.offsetLeft ?? 0;
const offsetTop = vv?.offsetTop ?? 0;
const pad = 8;
let ax = x;
let ay = y;
if (ax + rect.width > window.innerWidth - pad) {
ax = window.innerWidth - pad - rect.width;
let ax = x - offsetLeft;
let ay = y - offsetTop;
if (ax + rect.width > viewportWidth - pad) {
ax = viewportWidth - pad - rect.width;
}
if (ay + rect.height > window.innerHeight - pad) {
ay = window.innerHeight - pad - rect.height;
if (ay + rect.height > viewportHeight - pad) {
ay = viewportHeight - pad - rect.height;
}
if (ax < pad) ax = pad;
if (ay < pad) ay = pad;
setAdjustedPos({ x: ax, y: ay });
setAdjustedPos({ x: ax + offsetLeft, y: ay + offsetTop });
}, [x, y]);
// Close on Escape
@@ -291,21 +310,113 @@ export function FileBrowser({
const [dialog, setDialog] = useState<DialogState>(INITIAL_DIALOG);
const [operationLoading, setOperationLoading] = useState(false);
const [operationError, setOperationError] = useState<string | null>(null);
const [isLongPressing, setIsLongPressing] = useState(false);
const [longPressTargetPath, setLongPressTargetPath] = useState<string | null>(null);
const longPressTimerRef = useRef<number | null>(null);
const longPressFeedbackTimerRef = useRef<number | null>(null);
const touchStartRef = useRef<TouchPoint | null>(null);
const touchOpenHandledRef = useRef(false);
const clearLongPressTimers = useCallback(() => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
if (longPressFeedbackTimerRef.current !== null) {
window.clearTimeout(longPressFeedbackTimerRef.current);
longPressFeedbackTimerRef.current = null;
}
}, []);
const cancelLongPress = useCallback(() => {
clearLongPressTimers();
touchStartRef.current = null;
setIsLongPressing(false);
setLongPressTargetPath(null);
}, [clearLongPressTimers]);
useEffect(() => {
return () => {
clearLongPressTimers();
};
}, [clearLongPressTimers]);
const openContextMenuAt = useCallback((x: number, y: number, entry: FileNode, fullPath: string) => {
setContextMenu({
visible: true,
x,
y,
entry,
entryFullPath: fullPath,
});
}, []);
const handleTouchStart = useCallback((e: React.TouchEvent, entry: FileNode, fullPath: string) => {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
if (!touch) return;
cancelLongPress();
touchStartRef.current = { x: touch.clientX, y: touch.clientY };
longPressFeedbackTimerRef.current = window.setTimeout(() => {
setIsLongPressing(true);
setLongPressTargetPath(fullPath);
}, LONG_PRESS_FEEDBACK_MS);
longPressTimerRef.current = window.setTimeout(() => {
const point = touchStartRef.current;
if (!point) return;
touchOpenHandledRef.current = true;
setIsLongPressing(false);
setLongPressTargetPath(null);
clearLongPressTimers();
openContextMenuAt(point.x, point.y, entry, fullPath);
}, LONG_PRESS_DURATION_MS);
}, [cancelLongPress, clearLongPressTimers, openContextMenuAt]);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
const start = touchStartRef.current;
const touch = e.touches[0];
if (!start || !touch) return;
if (
Math.abs(touch.clientX - start.x) > TOUCH_MOVE_THRESHOLD ||
Math.abs(touch.clientY - start.y) > TOUCH_MOVE_THRESHOLD
) {
cancelLongPress();
}
}, [cancelLongPress]);
const handleTouchEnd = useCallback(() => {
cancelLongPress();
}, [cancelLongPress]);
// Close context menu on scroll within the file browser
useEffect(() => {
if (!contextMenu.visible) return;
const browserList = document.querySelector(".file-browser-list");
const handleClose = () => setContextMenu(INITIAL_CONTEXT_MENU);
const handleClose = () => {
touchOpenHandledRef.current = false;
cancelLongPress();
setContextMenu(INITIAL_CONTEXT_MENU);
};
browserList?.addEventListener("scroll", handleClose);
return () => browserList?.removeEventListener("scroll", handleClose);
}, [contextMenu.visible]);
}, [cancelLongPress, contextMenu.visible]);
// Close context menu on click outside or Escape
useEffect(() => {
if (!contextMenu.visible) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setContextMenu(INITIAL_CONTEXT_MENU);
if (e.key === "Escape") {
touchOpenHandledRef.current = false;
setContextMenu(INITIAL_CONTEXT_MENU);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
@@ -314,18 +425,16 @@ export function FileBrowser({
const handleContextMenu = useCallback((e: React.MouseEvent, entry: FileNode) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
entry,
entryFullPath: entryPath(currentPath, entry.name),
});
}, [currentPath]);
cancelLongPress();
touchOpenHandledRef.current = false;
openContextMenuAt(e.clientX, e.clientY, entry, entryPath(currentPath, entry.name));
}, [cancelLongPress, currentPath, openContextMenuAt]);
const handleContextAction = useCallback((action: string) => {
if (!contextMenu.entry) return;
touchOpenHandledRef.current = false;
const entry = contextMenu.entry;
const fullPath = contextMenu.entryFullPath;
@@ -391,6 +500,21 @@ export function FileBrowser({
setOperationError(null);
}, []);
const handleFileNodeClick = useCallback((entry: FileNode, fullPath: string) => {
if (touchOpenHandledRef.current) {
touchOpenHandledRef.current = false;
return;
}
if (contextMenu.visible) return;
if (entry.type === "directory") {
onNavigate(fullPath);
} else {
onSelectFile(fullPath);
}
}, [contextMenu.visible, onNavigate, onSelectFile]);
if (loading) {
return (
<div className="file-browser-loading">
@@ -436,36 +560,38 @@ export function FileBrowser({
{entries.length === 0 ? (
<div className="file-browser-empty">(empty directory)</div>
) : (
entries.map((entry) => (
<div
key={entry.name}
className={`file-node file-node--${entry.type}`}
onClick={() => {
if (contextMenu.visible) return;
if (entry.type === "directory") {
onNavigate(currentPath === "." ? entry.name : `${currentPath}/${entry.name}`);
} else {
onSelectFile(currentPath === "." ? entry.name : `${currentPath}/${entry.name}`);
}
}}
onContextMenu={(e) => handleContextMenu(e, entry)}
>
<div className="file-node-icon">
{entry.type === "directory" ? (
<Folder size={16} />
) : (
<File size={16} />
entries.map((entry) => {
const fullPath = entryPath(currentPath, entry.name);
const isLongPressTarget = isLongPressing && longPressTargetPath === fullPath;
return (
<div
key={entry.name}
className={`file-node file-node--${entry.type} ${isLongPressTarget ? "file-node--long-pressing" : ""}`}
onClick={() => handleFileNodeClick(entry, fullPath)}
onContextMenu={(e) => handleContextMenu(e, entry)}
onTouchStart={(e) => handleTouchStart(e, entry, fullPath)}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<div className="file-node-icon">
{entry.type === "directory" ? (
<Folder size={16} />
) : (
<File size={16} />
)}
</div>
<div className="file-node-name">{entry.name}</div>
{entry.type === "file" && entry.size !== undefined && (
<div className="file-node-size">{formatBytes(entry.size)}</div>
)}
{entry.mtime && (
<div className="file-node-time">{formatTime(entry.mtime)}</div>
)}
</div>
<div className="file-node-name">{entry.name}</div>
{entry.type === "file" && entry.size !== undefined && (
<div className="file-node-size">{formatBytes(entry.size)}</div>
)}
{entry.mtime && (
<div className="file-node-time">{formatTime(entry.mtime)}</div>
)}
</div>
))
);
})
)}
</div>
@@ -476,7 +602,10 @@ export function FileBrowser({
y={contextMenu.y}
entry={contextMenu.entry}
onAction={handleContextAction}
onClose={() => setContextMenu(INITIAL_CONTEXT_MENU)}
onClose={() => {
touchOpenHandledRef.current = false;
setContextMenu(INITIAL_CONTEXT_MENU);
}}
/>
)}

View File

@@ -111,7 +111,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const modelMenuRef = useRef<HTMLDivElement>(null);
const modelMenuPortalRef = useRef<HTMLDivElement>(null);
const agentPickerRef = useRef<HTMLDivElement>(null);
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number } | null>(null);
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
@@ -516,6 +516,25 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
);
}, []);
const getEffectiveViewport = useCallback(() => {
const vv = window.visualViewport;
if (vv && vv.width > 0 && vv.height > 0) {
return {
width: vv.width,
height: vv.height,
offsetTop: vv.offsetTop,
offsetLeft: vv.offsetLeft,
};
}
return {
width: window.innerWidth,
height: window.innerHeight,
offsetTop: 0,
offsetLeft: 0,
};
}, []);
const updateActionsMenuPosition = useCallback(() => {
const trigger = actionsMenuRef.current?.querySelector(".quick-entry-actions-trigger") as HTMLElement | null;
if (!trigger) return;
@@ -563,44 +582,97 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const isMobile = viewportWidth <= 640;
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
const horizontalPadding = 16;
const verticalPadding = 16;
const gap = 4;
const isMobile = viewportWidth <= 768;
if (isMobile) {
// On mobile: use a wider menu that fills most of the viewport (32px side margins)
const mobileWidth = Math.min(viewportWidth - 32, 360);
const left = Math.max((viewportWidth - mobileWidth) / 2, 16);
setModelMenuPosition({
top: rect.bottom + 4,
left,
width: mobileWidth,
});
} else {
setModelMenuPosition({
top: rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 240),
});
}
}, []);
const preferredHeight = isMobile
? Math.min(viewportHeight * 0.6, 360)
: Math.min(viewportHeight * 0.5, 360);
const preferredWidth = isMobile
? Math.min(viewportWidth - horizontalPadding * 2, 360)
: Math.max(rect.width, 240);
const width = Math.min(
preferredWidth,
Math.max(viewportWidth - horizontalPadding * 2, 200),
);
const triggerTop = rect.top - offsetTop;
const triggerBottom = rect.bottom - offsetTop;
const triggerLeft = rect.left - offsetLeft;
const spaceBelow = viewportHeight - triggerBottom;
const spaceAbove = triggerTop;
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160);
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160);
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
const maxHeight = Math.max(
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
160,
);
const left = Math.min(
Math.max(triggerLeft, horizontalPadding),
viewportWidth - horizontalPadding - width,
) + offsetLeft;
const top = openUpward
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
: Math.min(
triggerBottom + gap + offsetTop,
viewportHeight + offsetTop - verticalPadding - maxHeight,
);
setModelMenuPosition({
top,
left,
width,
maxHeight,
});
}, [getEffectiveViewport]);
const updateRefineMenuPosition = useCallback(() => {
const trigger = refineMenuRef.current?.querySelector(".refine-button") as HTMLElement | null;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const isMobile = viewportWidth <= 640;
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
const horizontalPadding = 8;
const verticalPadding = 12;
const gap = 4;
const expectedMenuHeight = Math.min(200, Math.max(viewportHeight - verticalPadding * 2, 160));
const menuWidth = Math.min(200, viewportWidth - horizontalPadding * 2);
// Ensure the menu doesn't overflow off the right edge on small screens
const menuWidth = Math.min(200, viewportWidth - 16);
const left = Math.min(rect.left, viewportWidth - menuWidth - 8);
const triggerTop = rect.top - offsetTop;
const triggerBottom = rect.bottom - offsetTop;
const triggerLeft = rect.left - offsetLeft;
const spaceBelow = viewportHeight - triggerBottom;
const spaceAbove = triggerTop;
const openUpward = spaceBelow < expectedMenuHeight && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(triggerLeft, horizontalPadding),
viewportWidth - horizontalPadding - menuWidth,
) + offsetLeft;
const top = openUpward
? Math.max(verticalPadding + offsetTop, triggerTop - expectedMenuHeight - gap + offsetTop)
: Math.min(
triggerBottom + gap + offsetTop,
viewportHeight + offsetTop - verticalPadding - expectedMenuHeight,
);
setRefineMenuPosition({
top: rect.bottom + 4,
top,
left,
});
}, []);
}, [getEffectiveViewport]);
// Keep actions menu portal anchored during scroll/resize
useEffect(() => {
@@ -626,9 +698,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
const vv = window.visualViewport;
if (vv) {
vv.addEventListener("resize", handleReposition);
vv.addEventListener("scroll", handleReposition);
}
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
if (vv) {
vv.removeEventListener("resize", handleReposition);
vv.removeEventListener("scroll", handleReposition);
}
};
}, [isModelMenuOpen, updateModelMenuPosition]);
@@ -641,9 +723,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
const vv = window.visualViewport;
if (vv) {
vv.addEventListener("resize", handleReposition);
vv.addEventListener("scroll", handleReposition);
}
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
if (vv) {
vv.removeEventListener("resize", handleReposition);
vv.removeEventListener("scroll", handleReposition);
}
};
}, [isRefineMenuOpen, updateRefineMenuPosition]);
@@ -1163,6 +1255,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
top: `${modelMenuPosition.top}px`,
left: `${modelMenuPosition.left}px`,
width: `${modelMenuPosition.width}px`,
maxHeight: modelMenuPosition.maxHeight ? `${modelMenuPosition.maxHeight}px` : undefined,
overflowY: modelMenuPosition.maxHeight ? "auto" : undefined,
}}
>
{activeModelSubmenu === null ? (

View File

@@ -2,6 +2,12 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { Terminal, Play, Settings, Loader2, ChevronDown } from "lucide-react";
import { fetchScripts } from "../api";
interface DropdownPosition {
top: number;
left: number;
width: number;
}
export interface QuickScriptsDropdownProps {
onOpenScripts: () => void;
onRunScript: (name: string, command: string) => void;
@@ -30,10 +36,77 @@ export function QuickScriptsDropdown({
const [scripts, setScripts] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const getEffectiveViewport = useCallback(() => {
const vv = window.visualViewport;
if (vv && vv.width > 0 && vv.height > 0) {
return {
width: vv.width,
height: vv.height,
offsetTop: vv.offsetTop,
offsetLeft: vv.offsetLeft,
};
}
return {
width: window.innerWidth,
height: window.innerHeight,
offsetTop: 0,
offsetLeft: 0,
};
}, []);
const updateDropdownPosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const menu = menuRef.current;
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
const horizontalPadding = 16;
const verticalPadding = 16;
const gap = 6;
const measuredWidth = menu?.offsetWidth || Math.max(rect.width, 260);
const width = Math.min(
measuredWidth,
Math.max(viewportWidth - horizontalPadding * 2, 160),
);
const measuredHeight = menu?.offsetHeight || 280;
const constrainedHeight = Math.min(
measuredHeight,
Math.max(viewportHeight - verticalPadding * 2, 160),
);
const triggerTop = rect.top - offsetTop;
const triggerBottom = rect.bottom - offsetTop;
const triggerLeft = rect.left - offsetLeft;
const spaceBelow = viewportHeight - triggerBottom;
const spaceAbove = triggerTop;
const openUpward = spaceBelow < constrainedHeight && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(triggerLeft, horizontalPadding),
viewportWidth - horizontalPadding - width,
) + offsetLeft;
const top = openUpward
? Math.max(verticalPadding + offsetTop, triggerTop - constrainedHeight - gap + offsetTop)
: Math.min(
triggerBottom + gap + offsetTop,
viewportHeight + offsetTop - verticalPadding - constrainedHeight,
);
setDropdownPosition({ top, left, width });
}, [getEffectiveViewport]);
// Script entries sorted alphabetically
const scriptEntries = useMemo(() => {
return Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
@@ -112,10 +185,49 @@ export function QuickScriptsDropdown({
if (isOpen) {
setHighlightedIndex(-1);
// Focus menu for keyboard navigation
setTimeout(() => menuRef.current?.focus(), 0);
const timeoutId = window.setTimeout(() => menuRef.current?.focus(), 0);
return () => window.clearTimeout(timeoutId);
}
setDropdownPosition(null);
}, [isOpen]);
// Position dropdown when opening and whenever content size changes.
useEffect(() => {
if (!isOpen) return;
const rafId = requestAnimationFrame(() => {
updateDropdownPosition();
});
return () => cancelAnimationFrame(rafId);
}, [isOpen, loading, scriptEntries.length, showFooter, updateDropdownPosition]);
// Keep dropdown anchored on viewport and container changes.
useEffect(() => {
if (!isOpen) return;
const handleReposition = () => updateDropdownPosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
const vv = window.visualViewport;
if (vv) {
vv.addEventListener("resize", handleReposition);
vv.addEventListener("scroll", handleReposition);
}
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
if (vv) {
vv.removeEventListener("resize", handleReposition);
vv.removeEventListener("scroll", handleReposition);
}
};
}, [isOpen, updateDropdownPosition]);
// Handle keyboard navigation within dropdown
const handleDropdownKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -208,6 +320,17 @@ export function QuickScriptsDropdown({
aria-label="Scripts"
onKeyDown={handleDropdownKeyDown}
data-testid="quick-scripts-dropdown"
style={
dropdownPosition
? {
position: "fixed",
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
right: "auto",
}
: undefined
}
>
{loading ? (
<div className="quick-scripts-dropdown__loading" data-testid="quick-scripts-loading">

View File

@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { FileBrowser } from "../FileBrowser";
import type { FileNode } from "../../api";
@@ -71,10 +73,19 @@ function renderFileBrowser(overrides: Partial<typeof defaultProps> = {}) {
return render(<FileBrowser {...props} />);
}
function contextMenuClick(entryName: string) {
function contextMenuClick(entryName: string, coords: { x: number; y: number } = { x: 200, y: 300 }) {
const entry = screen.getByText(entryName).closest(".file-node");
if (!entry) throw new Error(`Entry not found: ${entryName}`);
fireEvent.contextMenu(entry, { clientX: 200, clientY: 300 });
fireEvent.contextMenu(entry, { clientX: coords.x, clientY: coords.y });
}
function touchStart(entryName: string, coords: { x: number; y: number } = { x: 200, y: 300 }) {
const entry = screen.getByText(entryName).closest(".file-node");
if (!entry) throw new Error(`Entry not found: ${entryName}`);
fireEvent.touchStart(entry, {
touches: [{ clientX: coords.x, clientY: coords.y }],
});
return entry;
}
// ── Tests ───────────────────────────────────────────────────────────────
@@ -82,6 +93,22 @@ function contextMenuClick(entryName: string) {
describe("FileBrowser", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 1024,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 768,
});
Object.defineProperty(window, "visualViewport", {
configurable: true,
writable: true,
value: undefined,
});
});
afterEach(() => {
@@ -171,6 +198,78 @@ describe("FileBrowser", () => {
expect(screen.getByRole("menu")).toBeDefined();
});
it("opens context menu on long-press for a file entry", () => {
vi.useFakeTimers();
const onSelectFile = vi.fn();
renderFileBrowser({ onSelectFile });
touchStart("readme.md", { x: 120, y: 180 });
act(() => {
vi.advanceTimersByTime(220);
});
const fileNode = screen.getByText("readme.md").closest(".file-node");
expect(fileNode?.classList.contains("file-node--long-pressing")).toBe(true);
act(() => {
vi.advanceTimersByTime(300);
});
expect(screen.getByRole("menu")).toBeDefined();
expect(onSelectFile).not.toHaveBeenCalled();
fireEvent.touchEnd(fileNode!);
});
it("opens context menu on long-press for a directory entry", () => {
vi.useFakeTimers();
const onNavigate = vi.fn();
renderFileBrowser({ onNavigate });
const dirNode = touchStart("src", { x: 160, y: 210 });
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.getByRole("menu")).toBeDefined();
expect(onNavigate).not.toHaveBeenCalled();
fireEvent.touchEnd(dirNode);
});
it("cancels long-press when touch moves beyond threshold", () => {
vi.useFakeTimers();
renderFileBrowser();
const fileNode = touchStart("readme.md", { x: 100, y: 100 });
fireEvent.touchMove(fileNode, {
touches: [{ clientX: 120, clientY: 120 }],
});
act(() => {
vi.advanceTimersByTime(700);
});
expect(screen.queryByRole("menu")).toBeNull();
expect(fileNode.classList.contains("file-node--long-pressing")).toBe(false);
});
it("keeps single-tap selection behavior on touch devices", () => {
vi.useFakeTimers();
const onSelectFile = vi.fn();
renderFileBrowser({ onSelectFile });
const fileNode = touchStart("readme.md", { x: 120, y: 160 });
act(() => {
vi.advanceTimersByTime(120);
});
fireEvent.touchEnd(fileNode);
fireEvent.click(fileNode);
expect(onSelectFile).toHaveBeenCalledWith("readme.md");
expect(screen.queryByRole("menu")).toBeNull();
});
// ── Context Menu Items for Files ────────────────────────────────────
it("shows file context menu with Download option (not Download as ZIP)", () => {
@@ -217,6 +316,64 @@ describe("FileBrowser", () => {
expect(screen.queryByRole("menu")).toBeNull();
});
it("clamps context menu position within visual viewport bounds", async () => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 640,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 700,
});
Object.defineProperty(window, "visualViewport", {
configurable: true,
writable: true,
value: {
width: 320,
height: 480,
offsetTop: 20,
offsetLeft: 10,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
});
const nativeGetRect = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function () {
if (this.classList?.contains("file-browser-context-menu")) {
return {
x: 0,
y: 0,
width: 180,
height: 220,
top: 0,
right: 180,
bottom: 220,
left: 0,
toJSON: () => ({}),
};
}
return nativeGetRect.call(this);
});
renderFileBrowser();
contextMenuClick("readme.md", { x: 400, y: 500 });
const menu = await screen.findByRole("menu");
await waitFor(() => {
expect(menu).toHaveStyle({ left: "142px", top: "272px" });
});
});
it("defines 44px mobile touch targets for context menu items", () => {
const cssPath = resolve(process.cwd(), "app/styles.css");
const css = readFileSync(cssPath, "utf8");
expect(css).toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*\.file-browser-context-menu__item\s*\{[\s\S]*min-height:\s*44px;/);
});
// ── Download Actions ────────────────────────────────────────────────
it("opens download URL for file when Download is clicked", () => {

View File

@@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QuickScriptsDropdown } from "../QuickScriptsDropdown";
import { fetchScripts } from "../../api";
vi.mock("../../api", () => ({
fetchScripts: vi.fn(),
}));
const onOpenScripts = vi.fn();
const onRunScript = vi.fn();
const MOCK_SCRIPTS = {
build: "pnpm build",
lint: "pnpm lint",
test: "pnpm test",
};
describe("QuickScriptsDropdown", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
vi.mocked(fetchScripts).mockResolvedValue(MOCK_SCRIPTS);
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 1280,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 900,
});
Object.defineProperty(window, "visualViewport", {
configurable: true,
writable: true,
value: undefined,
});
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
} as MediaQueryList));
});
function renderDropdown() {
render(
<QuickScriptsDropdown
onOpenScripts={onOpenScripts}
onRunScript={onRunScript}
/>,
);
}
function mockTriggerRect(rect: Partial<DOMRect>) {
const trigger = screen.getByTestId("scripts-btn");
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
x: rect.left ?? 0,
y: rect.top ?? 0,
width: rect.width ?? 80,
height: rect.height ?? 32,
top: rect.top ?? 0,
right: (rect.left ?? 0) + (rect.width ?? 80),
bottom: (rect.top ?? 0) + (rect.height ?? 32),
left: rect.left ?? 0,
toJSON: () => ({}),
});
return trigger;
}
it("renders below trigger when space is available", async () => {
const user = userEvent.setup();
renderDropdown();
const trigger = mockTriggerRect({ top: 120, left: 220, width: 120, height: 36 });
await user.click(trigger);
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
await waitFor(() => {
expect(dropdown.style.position).toBe("fixed");
expect(dropdown.style.top).toBe("162px");
expect(dropdown.style.left).toBe("220px");
expect(dropdown.style.width).toBe("260px");
});
});
it("repositions above trigger when viewport bottom is near", async () => {
const user = userEvent.setup();
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 375,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 667,
});
renderDropdown();
const trigger = mockTriggerRect({ top: 560, left: 330, width: 120, height: 36 });
await user.click(trigger);
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
await waitFor(() => {
expect(dropdown.style.top).toBe("274px");
expect(dropdown.style.left).toBe("99px");
expect(dropdown.style.width).toBe("260px");
});
});
it("clamps horizontal position to viewport edges on small screens", async () => {
const user = userEvent.setup();
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 360,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 700,
});
renderDropdown();
const trigger = mockTriggerRect({ top: 140, left: -40, width: 120, height: 36 });
await user.click(trigger);
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
await waitFor(() => {
expect(dropdown.style.left).toBe("16px");
});
});
it("repositions on window resize", async () => {
const user = userEvent.setup();
renderDropdown();
const triggerRect = { top: 120, left: 220, width: 120, height: 36 };
const trigger = mockTriggerRect(triggerRect);
await user.click(trigger);
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
await waitFor(() => {
expect(dropdown.style.top).toBe("162px");
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 360,
});
fireEvent(window, new Event("resize"));
await waitFor(() => {
expect(dropdown.style.top).toBe("64px");
});
});
it("keeps keyboard navigation behavior (arrow keys, enter, escape)", async () => {
const user = userEvent.setup();
renderDropdown();
const trigger = mockTriggerRect({ top: 120, left: 220, width: 120, height: 36 });
await user.click(trigger);
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
await user.click(trigger);
await screen.findByTestId("quick-scripts-dropdown");
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
expect(dropdown).toBeTruthy();
});
});

View File

@@ -0,0 +1,37 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const stylesPath = path.resolve(__dirname, "../../styles.css");
describe("mobile dropdown positioning and momentum scrolling css", () => {
it("includes iOS momentum scrolling for dep-dropdown", () => {
const css = fs.readFileSync(stylesPath, "utf-8");
expect(css).toMatch(/\.dep-dropdown[\s\S]*-webkit-overflow-scrolling:\s*touch;/);
});
it("includes iOS momentum scrolling for file-browser-list", () => {
const css = fs.readFileSync(stylesPath, "utf-8");
expect(css).toMatch(/\.file-browser-list[\s\S]*-webkit-overflow-scrolling:\s*touch;/);
});
it("includes modal momentum scrolling selectors inside the 768px mobile media query", () => {
const css = fs.readFileSync(stylesPath, "utf-8");
const momentumBlockMatch = css.match(/@media \(max-width: 768px\)\s*\{[\s\S]*\/\* iOS momentum scrolling for all modal content areas \*\/[\s\S]*?\}/);
expect(momentumBlockMatch).toBeTruthy();
const block = momentumBlockMatch?.[0] ?? "";
expect(block).toContain(".modal-content");
expect(block).toContain(".modal-body");
expect(block).toContain(".modal-scroll");
expect(block).toContain(".task-detail-content");
expect(block).toContain(".agent-log-content");
expect(block).toContain(".file-browser-list");
expect(block).toContain(".settings-content");
expect(block).toContain(".activity-log-content");
expect(block).toContain("-webkit-overflow-scrolling: touch;");
});
});

View File

@@ -23450,3 +23450,58 @@ html .column.drag-over * {
background: var(--border);
margin: 8px 16px;
}
/* FN-1137: Quick scripts dropdown mobile viewport handling */
@media (max-width: 768px) {
.quick-scripts-dropdown__menu {
max-height: min(60vh, 400px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
/* FN-1137: mobile dep dropdown overflow handling */
@media (max-width: 768px) {
.dep-dropdown {
max-height: min(50vh, 240px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
/* FN-1137: file browser long-press and mobile context menu touch targets */
.file-node {
touch-action: manipulation;
}
.file-node--long-pressing {
background: var(--card-hover);
}
@media (max-width: 768px) {
.file-browser-context-menu__item {
min-height: 44px;
}
}
/* FN-1137: iOS momentum scrolling conventions */
.dep-dropdown,
.model-combobox-list,
.quick-scripts-dropdown__list,
.file-browser-list {
-webkit-overflow-scrolling: touch;
}
@media (max-width: 768px) {
/* iOS momentum scrolling for all modal content areas */
.modal-content,
.modal-body,
.modal-scroll,
.task-detail-content,
.agent-log-content,
.file-browser-list,
.settings-content,
.activity-log-content {
-webkit-overflow-scrolling: touch;
}
}