fix(dashboard): mobile keyboard scroll lock, auto-reload hash, ChatView snap
- Add useMobileScrollLock hook using position:fixed body lock (Bootstrap/ Headless UI/Stripe pattern) to prevent iOS Safari from shifting the document and visualViewport when an input inside a fixed-position modal is focused. Wire into 15 input-bearing modals plus ChatView, replacing ChatView's inline body-overflow effect. - Widen computeBuildVersion in vite.config.ts to hash the entire app/ tree so the version-check poll actually notices rebuilds (FN-3333 follow-up; previously only main.tsx and package.json were hashed). - ChatView: on input blur, suppress keyboard-aware sizing for 450ms while reserving mobile-nav-bar space, so the composer snaps to its final height in one move instead of crawling down with iOS's keyboard slide and then jumping again when the nav bar reappears. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
9
.changeset/fix-mobile-dashboard-pushed-up.md
Normal file
9
.changeset/fix-mobile-dashboard-pushed-up.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
"@fusion/dashboard": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix mobile keyboard regressions in the dashboard.
|
||||||
|
|
||||||
|
- **Dashboard pushed up after closing a modal on mobile.** Adds a shared `useMobileScrollLock` hook that pins `body` with `position: fixed; top: -scrollY; width: 100%` while a fullscreen mobile overlay is open and restores scroll on cleanup — the same pattern Bootstrap, Headless UI, and Stripe Elements use to prevent iOS Safari from scrolling the document (and shifting `visualViewport.offsetTop`) when an input inside a `position: fixed` overlay is focused. Reference-counted so nested overlays don't release each other's locks. Wired into TodoModal, PlanningModeModal, TaskDetailModal, NewTaskModal, SettingsModal, MailboxModal, AddNodeModal, MissionInterviewModal, MilestoneSliceInterviewModal, SubtaskBreakdownModal, GitHubImportModal, AgentGenerationModal, AgentImportModal, ScriptsModal, ResearchTaskActionModal, and ChatView (replacing its inline body-overflow effect).
|
||||||
|
- **Auto-reload prompt missed rebuilds.** Widens `computeBuildVersion` in `vite.config.ts` to hash the entire `app/` source tree (FN-3333 follow-up). The previous version only hashed `app/main.tsx` and `package.json`, so edits to any other component or stylesheet produced an identical build version and the version-check poll never noticed the rebuild.
|
||||||
|
- **ChatView composer crawled down with iOS's keyboard-dismiss animation.** On blur, ChatView now suppresses keyboard-aware sizing for ~450ms so the composer snaps back to full height immediately instead of following iOS's slow keyboard slide-out (matches the existing QuickChatFAB behavior).
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import "./AddNodeModal.css";
|
import "./AddNodeModal.css";
|
||||||
|
|
||||||
export interface AddNodeInput {
|
export interface AddNodeInput {
|
||||||
@@ -59,6 +60,7 @@ function validateInput(input: AddNodeInput): FormErrors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeModalProps) {
|
export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeModalProps) {
|
||||||
|
useMobileScrollLock(isOpen);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [type, setType] = useState<"local" | "remote">("local");
|
const [type, setType] = useState<"local" | "remote">("local");
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import type { AgentGenerationSpec } from "../api";
|
import type { AgentGenerationSpec } from "../api";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import {
|
import {
|
||||||
startAgentGeneration,
|
startAgentGeneration,
|
||||||
generateAgentSpec,
|
generateAgentSpec,
|
||||||
@@ -37,6 +38,7 @@ export function AgentGenerationModal({
|
|||||||
onGenerated,
|
onGenerated,
|
||||||
projectId,
|
projectId,
|
||||||
}: AgentGenerationModalProps) {
|
}: AgentGenerationModalProps) {
|
||||||
|
useMobileScrollLock(isOpen);
|
||||||
const [roleDescription, setRoleDescription] = useState("");
|
const [roleDescription, setRoleDescription] = useState("");
|
||||||
const [view, setView] = useState<ViewState>({ type: "input" });
|
const [view, setView] = useState<ViewState>({ type: "input" });
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "./AgentImportModal.css";
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, Globe, Search, RefreshCw } from "lucide-react";
|
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, Globe, Search, RefreshCw } from "lucide-react";
|
||||||
import { fetchCompanies, type CompanyEntry } from "../api";
|
import { fetchCompanies, type CompanyEntry } from "../api";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
|
|
||||||
export interface AgentImportModalProps {
|
export interface AgentImportModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -126,6 +127,7 @@ function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
|
|||||||
* Flow: Input → Preview parsed agents → Import → Show results
|
* Flow: Input → Preview parsed agents → Import → Show results
|
||||||
*/
|
*/
|
||||||
export function AgentImportModal({ isOpen, onClose, onImported, projectId }: AgentImportModalProps) {
|
export function AgentImportModal({ isOpen, onClose, onImported, projectId }: AgentImportModalProps) {
|
||||||
|
useMobileScrollLock(isOpen);
|
||||||
const [step, setStep] = useState<ModalStep>("input");
|
const [step, setStep] = useState<ModalStep>("input");
|
||||||
const [inputMethod, setInputMethod] = useState<InputMethod>("paste");
|
const [inputMethod, setInputMethod] = useState<InputMethod>("paste");
|
||||||
const [manifestContent, setManifestContent] = useState("");
|
const [manifestContent, setManifestContent] = useState("");
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { AgentMentionPopup } from "./AgentMentionPopup";
|
|||||||
import { FileMentionPopup } from "./FileMentionPopup";
|
import { FileMentionPopup } from "./FileMentionPopup";
|
||||||
import { useFileMention } from "../hooks/useFileMention";
|
import { useFileMention } from "../hooks/useFileMention";
|
||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
|
|
||||||
export interface ChatViewProps {
|
export interface ChatViewProps {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -784,14 +785,32 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
enabled: isMobile && !!activeSession,
|
enabled: isMobile && !!activeSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadKeyboardStyle: CSSProperties =
|
// Suppresses keyboard-following styles for ~450ms after blur so the
|
||||||
keyboardOpen
|
// composer snaps to full height immediately instead of crawling down with
|
||||||
? ({
|
// iOS's keyboard-dismiss animation. See `handleInputBlur` below for where
|
||||||
|
// this is set, and QuickChatFAB's `suppressVvShrinkRef` for the same trick
|
||||||
|
// applied to the FAB panel.
|
||||||
|
const [suppressKeyboardStyle, setSuppressKeyboardStyle] = useState(false);
|
||||||
|
const suppressKeyboardStyleTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const threadKeyboardStyle: CSSProperties = keyboardOpen
|
||||||
|
? suppressKeyboardStyle
|
||||||
|
? // Suppress window: snap composer to the final post-keyboard height
|
||||||
|
// immediately, but reserve space for the MobileNavBar that is about
|
||||||
|
// to reappear once App-level keyboardOpen settles to false. Without
|
||||||
|
// the reservation the composer briefly extends below the nav bar
|
||||||
|
// position, then jumps up when the nav reappears.
|
||||||
|
({
|
||||||
|
"--keyboard-overlap":
|
||||||
|
"calc(var(--mobile-nav-height, 44px) + env(safe-area-inset-bottom, 0px))",
|
||||||
|
"--vv-offset-top": "0px",
|
||||||
|
} as CSSProperties)
|
||||||
|
: ({
|
||||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||||
"--vv-offset-top": `${viewportOffsetTop}px`,
|
"--vv-offset-top": `${viewportOffsetTop}px`,
|
||||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||||
} as CSSProperties)
|
} as CSSProperties)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const filteredSkills = useMemo(() => {
|
const filteredSkills = useMemo(() => {
|
||||||
const normalizedFilter = skillFilter.trim().toLowerCase();
|
const normalizedFilter = skillFilter.trim().toLowerCase();
|
||||||
@@ -858,20 +877,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
}, [keyboardOverlap]);
|
}, [keyboardOverlap]);
|
||||||
|
|
||||||
// Lock body scroll on mobile while the keyboard is up so iOS can't shift
|
// Lock body scroll on mobile while the keyboard is up so iOS can't shift
|
||||||
// the visual viewport (offsetTop > 0). Avoid forcing window.scrollTo(0, 0),
|
// the visual viewport (offsetTop > 0). Shared hook also restores
|
||||||
// which can jump the page when send briefly toggles focus.
|
// window.scrollTo(0, 0) on cleanup to recover from any iOS drift.
|
||||||
useEffect(() => {
|
useMobileScrollLock(isMobile && keyboardOpen);
|
||||||
if (!isMobile || !keyboardOpen) return;
|
|
||||||
const html = document.documentElement;
|
|
||||||
const body = document.body;
|
|
||||||
const prev = { htmlOverflow: html.style.overflow, bodyOverflow: body.style.overflow };
|
|
||||||
html.style.overflow = "hidden";
|
|
||||||
body.style.overflow = "hidden";
|
|
||||||
return () => {
|
|
||||||
html.style.overflow = prev.htmlOverflow;
|
|
||||||
body.style.overflow = prev.bodyOverflow;
|
|
||||||
};
|
|
||||||
}, [isMobile, keyboardOpen]);
|
|
||||||
|
|
||||||
// Close context menu on outside click
|
// Close context menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1326,6 +1334,22 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-grow the composer ahead of iOS's keyboard-dismiss animation so it
|
||||||
|
// snaps to full height immediately instead of crawling down with the
|
||||||
|
// keyboard. The suppress window covers the iOS slide-out duration
|
||||||
|
// (~250ms with margin) during which visualViewport keeps reporting
|
||||||
|
// mid-dismiss heights that would otherwise drag the panel back down.
|
||||||
|
if (isMobile) {
|
||||||
|
setSuppressKeyboardStyle(true);
|
||||||
|
if (suppressKeyboardStyleTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(suppressKeyboardStyleTimeoutRef.current);
|
||||||
|
}
|
||||||
|
suppressKeyboardStyleTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setSuppressKeyboardStyle(false);
|
||||||
|
suppressKeyboardStyleTimeoutRef.current = null;
|
||||||
|
}, 450);
|
||||||
|
}
|
||||||
|
|
||||||
if (hideSkillMenuTimeoutRef.current !== null) {
|
if (hideSkillMenuTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||||||
}
|
}
|
||||||
@@ -1339,9 +1363,16 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
fileMention.dismissMention();
|
fileMention.dismissMention();
|
||||||
hideSkillMenuTimeoutRef.current = null;
|
hideSkillMenuTimeoutRef.current = null;
|
||||||
}, 120);
|
}, 120);
|
||||||
}, [fileMention, focusComposerInput]);
|
}, [fileMention, focusComposerInput, isMobile]);
|
||||||
|
|
||||||
const handleInputFocus = useCallback(() => {
|
const handleInputFocus = useCallback(() => {
|
||||||
|
// If the user re-focuses inside the post-blur suppress window, lift it
|
||||||
|
// immediately so the keyboard-aware sizing kicks back in.
|
||||||
|
if (suppressKeyboardStyleTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(suppressKeyboardStyleTimeoutRef.current);
|
||||||
|
suppressKeyboardStyleTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setSuppressKeyboardStyle(false);
|
||||||
if (hideSkillMenuTimeoutRef.current !== null) {
|
if (hideSkillMenuTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||||||
hideSkillMenuTimeoutRef.current = null;
|
hideSkillMenuTimeoutRef.current = null;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "../api";
|
} from "../api";
|
||||||
import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot } from "lucide-react";
|
import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot } from "lucide-react";
|
||||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
|
|
||||||
interface GitHubImportModalProps {
|
interface GitHubImportModalProps {
|
||||||
@@ -30,6 +31,7 @@ const MOBILE_BREAKPOINT = 640;
|
|||||||
type TabType = "issues" | "pulls";
|
type TabType = "issues" | "pulls";
|
||||||
|
|
||||||
export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId }: GitHubImportModalProps) {
|
export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId }: GitHubImportModalProps) {
|
||||||
|
useMobileScrollLock(isOpen);
|
||||||
const [owner, setOwner] = useState("");
|
const [owner, setOwner] = useState("");
|
||||||
const [repo, setRepo] = useState("");
|
const [repo, setRepo] = useState("");
|
||||||
const [labels, setLabels] = useState("");
|
const [labels, setLabels] = useState("");
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "../api";
|
} from "../api";
|
||||||
import { MessageComposer } from "./MessageComposer";
|
import { MessageComposer } from "./MessageComposer";
|
||||||
import type { Agent } from "../api";
|
import type { Agent } from "../api";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────
|
||||||
@@ -90,6 +91,7 @@ export function MailboxModal({
|
|||||||
addToast,
|
addToast,
|
||||||
agents = [],
|
agents = [],
|
||||||
}: MailboxModalProps) {
|
}: MailboxModalProps) {
|
||||||
|
useMobileScrollLock(isOpen);
|
||||||
const [activeTab, setActiveTab] = useState<MailboxTab>("inbox");
|
const [activeTab, setActiveTab] = useState<MailboxTab>("inbox");
|
||||||
const [inbox, setInbox] = useState<InboxResponse | null>(null);
|
const [inbox, setInbox] = useState<InboxResponse | null>(null);
|
||||||
const [outbox, setOutbox] = useState<OutboxResponse | null>(null);
|
const [outbox, setOutbox] = useState<OutboxResponse | null>(null);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { ConversationHistory } from "./ConversationHistory";
|
import { ConversationHistory } from "./ConversationHistory";
|
||||||
import { useSessionLock } from "../hooks/useSessionLock";
|
import { useSessionLock } from "../hooks/useSessionLock";
|
||||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||||
|
|
||||||
interface MilestoneSliceInterviewModalProps {
|
interface MilestoneSliceInterviewModalProps {
|
||||||
@@ -73,6 +74,7 @@ export function MilestoneSliceInterviewModal({
|
|||||||
projectId,
|
projectId,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
}: MilestoneSliceInterviewModalProps) {
|
}: MilestoneSliceInterviewModalProps) {
|
||||||
|
useMobileScrollLock(isOpen);
|
||||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
|
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
|||||||
import { useSessionLock } from "../hooks/useSessionLock";
|
import { useSessionLock } from "../hooks/useSessionLock";
|
||||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||||
|
|
||||||
// Helper functions for model selection
|
// Helper functions for model selection
|
||||||
@@ -107,6 +108,7 @@ export function MissionInterviewModal({
|
|||||||
initialGoal: initialGoalProp,
|
initialGoal: initialGoalProp,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
}: MissionInterviewModalProps) {
|
}: MissionInterviewModalProps) {
|
||||||
|
useMobileScrollLock(isOpen);
|
||||||
const [missionGoal, setMissionGoal] = useState("");
|
const [missionGoal, setMissionGoal] = useState("");
|
||||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useSetupReadiness } from "../hooks/useSetupReadiness";
|
|||||||
import { SetupWarningBanner } from "./SetupWarningBanner";
|
import { SetupWarningBanner } from "./SetupWarningBanner";
|
||||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useNodes } from "../hooks/useNodes";
|
import { useNodes } from "../hooks/useNodes";
|
||||||
|
|
||||||
interface NewTaskModalProps {
|
interface NewTaskModalProps {
|
||||||
@@ -25,6 +26,7 @@ interface NewTaskModalProps {
|
|||||||
|
|
||||||
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
|
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
|
useMobileScrollLock(isOpen);
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { useSessionLock } from "../hooks/useSessionLock";
|
|||||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||||
import { useViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||||
|
|
||||||
interface PlanningModeModalProps {
|
interface PlanningModeModalProps {
|
||||||
@@ -189,6 +190,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
|
|
||||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
|
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
|
||||||
useMobileKeyboard({ enabled: viewportMode === "mobile" });
|
useMobileKeyboard({ enabled: viewportMode === "mobile" });
|
||||||
|
useMobileScrollLock(viewportMode === "mobile" && isOpen);
|
||||||
|
|
||||||
const modalKeyboardStyle: CSSProperties = keyboardOpen
|
const modalKeyboardStyle: CSSProperties = keyboardOpen
|
||||||
? ({
|
? ({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { Task, TaskPriority } from "@fusion/core";
|
import type { Task, TaskPriority } from "@fusion/core";
|
||||||
import { fetchTasks } from "../api";
|
import { fetchTasks } from "../api";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import type { ResearchRunDetail } from "../research-types";
|
import type { ResearchRunDetail } from "../research-types";
|
||||||
import "./ResearchTaskActionModal.css";
|
import "./ResearchTaskActionModal.css";
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ interface ResearchTaskActionModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ResearchTaskActionModal({ open, mode, run, finding, projectId, onClose, onConfirm }: ResearchTaskActionModalProps) {
|
export function ResearchTaskActionModal({ open, mode, run, finding, projectId, onClose, onConfirm }: ResearchTaskActionModalProps) {
|
||||||
|
useMobileScrollLock(open);
|
||||||
const [attachExport, setAttachExport] = useState(false);
|
const [attachExport, setAttachExport] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import { fetchScripts, addScript, removeScript, type ScriptEntry } from "../api";
|
import { fetchScripts, addScript, removeScript, type ScriptEntry } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
@@ -44,6 +45,7 @@ function truncateCommand(command: string, maxLength: number = 60): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript }: ScriptsModalProps) {
|
export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript }: ScriptsModalProps) {
|
||||||
|
useMobileScrollLock(isOpen);
|
||||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { CustomProvidersSection } from "./CustomProvidersSection";
|
|||||||
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
|
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
|
||||||
import { appendTokenQuery } from "../auth";
|
import { appendTokenQuery } from "../auth";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useNodes } from "../hooks/useNodes";
|
import { useNodes } from "../hooks/useNodes";
|
||||||
import { NodeHealthDot } from "./NodeHealthDot";
|
import { NodeHealthDot } from "./NodeHealthDot";
|
||||||
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
||||||
@@ -351,6 +352,7 @@ export function SettingsModal({
|
|||||||
onReopenOnboarding,
|
onReopenOnboarding,
|
||||||
}: SettingsModalProps) {
|
}: SettingsModalProps) {
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
|
useMobileScrollLock(true);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const settingsContentRef = useRef<HTMLDivElement>(null);
|
const settingsContentRef = useRef<HTMLDivElement>(null);
|
||||||
useModalResizePersist(modalRef, true, "fusion:settings-modal-size");
|
useModalResizePersist(modalRef, true, "fusion:settings-modal-size");
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ConversationHistory } from "./ConversationHistory";
|
|||||||
import { useSessionLock } from "../hooks/useSessionLock";
|
import { useSessionLock } from "../hooks/useSessionLock";
|
||||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||||
|
|
||||||
interface SubtaskBreakdownModalProps {
|
interface SubtaskBreakdownModalProps {
|
||||||
@@ -72,6 +73,7 @@ function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, resumeSessionId }: SubtaskBreakdownModalProps) {
|
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, resumeSessionId }: SubtaskBreakdownModalProps) {
|
||||||
|
useMobileScrollLock(isOpen);
|
||||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||||
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
|
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
|
||||||
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
|
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "./TaskDetailModal.css";
|
|||||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft } from "lucide-react";
|
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft } from "lucide-react";
|
||||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@@ -2696,6 +2697,7 @@ export function TaskDetailContent({
|
|||||||
export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
|
export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
useModalResizePersist(modalRef, true, "task-detail-modal-size");
|
useModalResizePersist(modalRef, true, "task-detail-modal-size");
|
||||||
|
useMobileScrollLock(true);
|
||||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect } from "react";
|
|||||||
import { ListChecks, X } from "lucide-react";
|
import { ListChecks, X } from "lucide-react";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useViewportMode } from "./Header";
|
import { useViewportMode } from "./Header";
|
||||||
import { TodoView } from "./TodoView";
|
import { TodoView } from "./TodoView";
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export function TodoModal({ onClose, projectId, addToast, onPlanningMode }: Todo
|
|||||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||||
enabled: isMobile,
|
enabled: isMobile,
|
||||||
});
|
});
|
||||||
|
useMobileScrollLock(isMobile);
|
||||||
|
|
||||||
const modalKeyboardStyle: React.CSSProperties =
|
const modalKeyboardStyle: React.CSSProperties =
|
||||||
keyboardOpen
|
keyboardOpen
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { _resetLockState, useMobileScrollLock } from "../useMobileScrollLock";
|
||||||
|
|
||||||
|
describe("useMobileScrollLock", () => {
|
||||||
|
let savedInnerWidth: number;
|
||||||
|
let savedMaxTouchPoints: number;
|
||||||
|
let savedOntouchstart: typeof window.ontouchstart;
|
||||||
|
let scrollSpy: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetLockState();
|
||||||
|
savedInnerWidth = window.innerWidth;
|
||||||
|
savedMaxTouchPoints = navigator.maxTouchPoints;
|
||||||
|
savedOntouchstart = window.ontouchstart;
|
||||||
|
document.documentElement.style.cssText = "";
|
||||||
|
document.body.style.cssText = "";
|
||||||
|
scrollSpy = vi.fn();
|
||||||
|
window.scrollTo = scrollSpy as unknown as typeof window.scrollTo;
|
||||||
|
Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { value: savedInnerWidth, writable: true, configurable: true });
|
||||||
|
Object.defineProperty(navigator, "maxTouchPoints", { value: savedMaxTouchPoints, configurable: true });
|
||||||
|
Object.defineProperty(window, "ontouchstart", { value: savedOntouchstart, writable: true, configurable: true });
|
||||||
|
document.documentElement.style.cssText = "";
|
||||||
|
document.body.style.cssText = "";
|
||||||
|
_resetLockState();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeMobile() {
|
||||||
|
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
|
||||||
|
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
|
||||||
|
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDesktop() {
|
||||||
|
delete (window as unknown as { ontouchstart?: unknown }).ontouchstart;
|
||||||
|
Object.defineProperty(navigator, "maxTouchPoints", { value: 0, configurable: true });
|
||||||
|
Object.defineProperty(window, "innerWidth", { value: 1280, writable: true, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("pins body with position:fixed and overflow:hidden on mobile when enabled", () => {
|
||||||
|
makeMobile();
|
||||||
|
Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true });
|
||||||
|
renderHook(() => useMobileScrollLock(true));
|
||||||
|
expect(document.body.style.position).toBe("fixed");
|
||||||
|
expect(document.body.style.top).toBe("-120px");
|
||||||
|
expect(document.body.style.width).toBe("100%");
|
||||||
|
expect(document.body.style.overflow).toBe("hidden");
|
||||||
|
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on desktop", () => {
|
||||||
|
makeDesktop();
|
||||||
|
renderHook(() => useMobileScrollLock(true));
|
||||||
|
expect(document.body.style.position).toBe("");
|
||||||
|
expect(document.documentElement.style.overflow).toBe("");
|
||||||
|
expect(scrollSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores prior styles and scroll position on cleanup", () => {
|
||||||
|
makeMobile();
|
||||||
|
document.body.style.position = "relative";
|
||||||
|
document.body.style.top = "5px";
|
||||||
|
Object.defineProperty(window, "scrollY", { value: 240, writable: true, configurable: true });
|
||||||
|
|
||||||
|
const { unmount } = renderHook(() => useMobileScrollLock(true));
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(document.body.style.position).toBe("relative");
|
||||||
|
expect(document.body.style.top).toBe("5px");
|
||||||
|
expect(document.body.style.overflow).toBe("");
|
||||||
|
expect(document.documentElement.style.overflow).toBe("");
|
||||||
|
expect(scrollSpy).toHaveBeenCalledWith(0, 240);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not lock when disabled", () => {
|
||||||
|
makeMobile();
|
||||||
|
renderHook(() => useMobileScrollLock(false));
|
||||||
|
expect(document.body.style.position).toBe("");
|
||||||
|
expect(scrollSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reference-counts so an inner unmount does not release an outer lock", () => {
|
||||||
|
makeMobile();
|
||||||
|
Object.defineProperty(window, "scrollY", { value: 80, writable: true, configurable: true });
|
||||||
|
|
||||||
|
const outer = renderHook(() => useMobileScrollLock(true));
|
||||||
|
const inner = renderHook(() => useMobileScrollLock(true));
|
||||||
|
expect(document.body.style.position).toBe("fixed");
|
||||||
|
|
||||||
|
inner.unmount();
|
||||||
|
expect(document.body.style.position).toBe("fixed");
|
||||||
|
expect(scrollSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
outer.unmount();
|
||||||
|
expect(document.body.style.position).toBe("");
|
||||||
|
expect(scrollSpy).toHaveBeenCalledWith(0, 80);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
packages/dashboard/app/hooks/useMobileScrollLock.ts
Normal file
112
packages/dashboard/app/hooks/useMobileScrollLock.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
function isMobileDevice(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
const hasTouchScreen =
|
||||||
|
"ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||||
|
const isNarrow = window.innerWidth <= 768;
|
||||||
|
return hasTouchScreen && isNarrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference-counted body scroll lock for fullscreen mobile overlays.
|
||||||
|
*
|
||||||
|
* Uses the `position: fixed; top: -scrollY` pattern (the same approach used
|
||||||
|
* by Bootstrap, Headless UI, and Stripe Elements) instead of just
|
||||||
|
* `overflow: hidden`. The reason: iOS Safari ignores `overflow: hidden` when
|
||||||
|
* an input inside a `position: fixed` overlay is focused — it scrolls the
|
||||||
|
* document to bring the caret above the soft keyboard, and after dismissal
|
||||||
|
* the document can be left scrolled with `visualViewport.offsetTop > 0`,
|
||||||
|
* shoving the underlying dashboard (header included) off the top of the
|
||||||
|
* screen with a matching gap at the bottom.
|
||||||
|
*
|
||||||
|
* Pinning `body` with `position: fixed` makes the document genuinely
|
||||||
|
* unscrollable, so iOS has nothing to do on focus and leaves the visible
|
||||||
|
* area aligned with the layout viewport.
|
||||||
|
*
|
||||||
|
* Reference counting matters because multiple overlays can be open at once
|
||||||
|
* (e.g. a confirm dialog over a TodoModal) — only the outermost lock should
|
||||||
|
* actually mutate styles, so an inner unmount doesn't release the lock for
|
||||||
|
* an outer overlay that is still open.
|
||||||
|
*/
|
||||||
|
let lockCount = 0;
|
||||||
|
let savedStyles: {
|
||||||
|
htmlOverflow: string;
|
||||||
|
bodyPosition: string;
|
||||||
|
bodyTop: string;
|
||||||
|
bodyLeft: string;
|
||||||
|
bodyRight: string;
|
||||||
|
bodyWidth: string;
|
||||||
|
bodyOverflow: string;
|
||||||
|
scrollY: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
function applyLock(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (lockCount > 0) {
|
||||||
|
lockCount += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
savedStyles = {
|
||||||
|
htmlOverflow: html.style.overflow,
|
||||||
|
bodyPosition: body.style.position,
|
||||||
|
bodyTop: body.style.top,
|
||||||
|
bodyLeft: body.style.left,
|
||||||
|
bodyRight: body.style.right,
|
||||||
|
bodyWidth: body.style.width,
|
||||||
|
bodyOverflow: body.style.overflow,
|
||||||
|
scrollY: window.scrollY,
|
||||||
|
};
|
||||||
|
html.style.overflow = "hidden";
|
||||||
|
body.style.position = "fixed";
|
||||||
|
body.style.top = `-${savedStyles.scrollY}px`;
|
||||||
|
body.style.left = "0";
|
||||||
|
body.style.right = "0";
|
||||||
|
body.style.width = "100%";
|
||||||
|
body.style.overflow = "hidden";
|
||||||
|
lockCount = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseLock(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (lockCount === 0) return;
|
||||||
|
lockCount -= 1;
|
||||||
|
if (lockCount > 0 || !savedStyles) return;
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
const { htmlOverflow, bodyPosition, bodyTop, bodyLeft, bodyRight, bodyWidth, bodyOverflow, scrollY } = savedStyles;
|
||||||
|
html.style.overflow = htmlOverflow;
|
||||||
|
body.style.position = bodyPosition;
|
||||||
|
body.style.top = bodyTop;
|
||||||
|
body.style.left = bodyLeft;
|
||||||
|
body.style.right = bodyRight;
|
||||||
|
body.style.width = bodyWidth;
|
||||||
|
body.style.overflow = bodyOverflow;
|
||||||
|
savedStyles = null;
|
||||||
|
// Snap back to where the user was before the lock started. With the body
|
||||||
|
// un-fixed, this scroll actually applies (vs. being a no-op while the
|
||||||
|
// body was overflow:hidden).
|
||||||
|
window.scrollTo(0, scrollY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: reset the module-level lock state. */
|
||||||
|
export function _resetLockState(): void {
|
||||||
|
lockCount = 0;
|
||||||
|
savedStyles = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock body scroll and pin position while a fullscreen mobile overlay is
|
||||||
|
* open. Recovers iOS visualViewport drift on cleanup. No-op on desktop.
|
||||||
|
*/
|
||||||
|
export function useMobileScrollLock(enabled: boolean): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !isMobileDevice()) return;
|
||||||
|
applyLock();
|
||||||
|
return () => {
|
||||||
|
releaseLock();
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig, type Plugin } from "vite";
|
import { defineConfig, type Plugin } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { writeFileSync, readFileSync } from "node:fs";
|
import { writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
@@ -26,16 +26,53 @@ function computeBuildVersion(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content hash of key source files — changes when source changes
|
// Content hash of the entire app/ source tree + package.json. Hashing only
|
||||||
const filesToHash = [resolve(__dirname, "app/main.tsx"), resolve(__dirname, "package.json")];
|
// a couple of entry files (the previous behavior) meant that edits to any
|
||||||
|
// other component or stylesheet produced an identical build version, so the
|
||||||
|
// dashboard's version-check poll never noticed the new bundle and the
|
||||||
|
// "reload available" prompt never fired (FN-3333 follow-up).
|
||||||
const hasher = createHash("sha1");
|
const hasher = createHash("sha1");
|
||||||
for (const f of filesToHash) {
|
const appDir = resolve(__dirname, "app");
|
||||||
|
// Collect, sort, then hash so the order is stable across platforms and runs.
|
||||||
|
const files: string[] = [];
|
||||||
|
const walk = (dir: string): void => {
|
||||||
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
|
entries = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry === "node_modules" || entry === "__tests__" || entry.startsWith(".")) continue;
|
||||||
|
const full = resolve(dir, entry);
|
||||||
|
let info: ReturnType<typeof statSync>;
|
||||||
|
try {
|
||||||
|
info = statSync(full);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (info.isDirectory()) {
|
||||||
|
walk(full);
|
||||||
|
} else if (info.isFile()) {
|
||||||
|
files.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(appDir);
|
||||||
|
files.sort();
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
hasher.update(f.slice(appDir.length));
|
||||||
hasher.update(readFileSync(f));
|
hasher.update(readFileSync(f));
|
||||||
} catch {
|
} catch {
|
||||||
// file may not exist during certain builds — skip
|
// file may have been deleted between readdir and read — skip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
hasher.update(readFileSync(resolve(__dirname, "package.json")));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
const contentHash = hasher.digest("hex").slice(0, 8);
|
const contentHash = hasher.digest("hex").slice(0, 8);
|
||||||
|
|
||||||
return `${prefix}-${contentHash}`;
|
return `${prefix}-${contentHash}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user