fix(dashboard): no resize-drag dismiss across all resizable modals + Claude CLI in Authenticated group
When a user drags the native CSS resize grip from inside a modal and releases the mouse over the overlay, the synthesised click event targets the common ancestor (the overlay) — fooling the existing e.target === e.currentTarget dismiss check. Audited every modal with `resize: both` and switched them to a shared mousedown→mouseup tracking pattern (new useOverlayDismiss hook) so dismiss only fires when both events land on the overlay. Modals fixed: TaskDetail, Settings, FileBrowser, GitHubImport, GitManager, ScheduledTasks, Scripts, WorkflowStepManager. (Terminal and AgentDetail were already fixed in 95566795e; PlanningModeModal already had the right pattern inline.) Also: in Settings → Authentication, the "Anthropic via Claude CLI" card now lives inside the Authenticated group when authenticated and the Available group otherwise, instead of floating at the top. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
||||
import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor";
|
||||
import { useWorkspaces } from "../hooks/useWorkspaces";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import { downloadFileUrl } from "../api";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
@@ -63,6 +64,7 @@ export function FileBrowserModal({
|
||||
const { projectName, workspaces } = useWorkspaces(projectId);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "fusion:files-modal-size");
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
const [currentWorkspace, setCurrentWorkspace] = useState(initialWorkspace);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
@@ -171,8 +173,8 @@ export function FileBrowserModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal file-browser-modal" ref={modalRef} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
<div className="modal file-browser-modal" ref={modalRef}>
|
||||
<div className="modal-header file-browser-modal-header">
|
||||
<div className="file-browser-header-title">
|
||||
<Folder size={18} />
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../api";
|
||||
import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot } from "lucide-react";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
|
||||
interface GitHubImportModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -55,6 +56,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
const mountedRef = useRef(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:github-modal-size");
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
// Mobile view state
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
@@ -352,7 +354,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
const showInlineErrorBanner = activeTab === "issues" ? showIssuesError : showPullsError;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true">
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
<div className="modal modal-lg github-import-modal" ref={modalRef}>
|
||||
<div className="modal-header github-import-modal__header">
|
||||
<div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getErrorMessage } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import type {
|
||||
GitStatus,
|
||||
GitCommit,
|
||||
@@ -179,6 +180,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const [sectionError, setSectionError] = useState<string | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:git-modal-size");
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
const copyToClipboard = useCopyToClipboard(addToast);
|
||||
|
||||
// ── Status state
|
||||
@@ -738,7 +740,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true">
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
<div className="modal gm-modal" ref={modalRef}>
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RoutineCard } from "./RoutineCard";
|
||||
import { RoutineEditor } from "./RoutineEditor";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
|
||||
/** Polling interval for auto-refreshing the schedule/routine list (30 seconds). */
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
@@ -104,12 +105,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose, routineView]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
// ── Routine CRUD handlers ───────────────────────────────────────────────
|
||||
|
||||
@@ -289,7 +285,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
const isShowingList =
|
||||
routineView === "list" && routines.length > 0;
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick}>
|
||||
<div className="modal-overlay open" {...overlayDismissProps}>
|
||||
<div ref={modalRef} className="modal modal-lg automation-modal" role="dialog" aria-modal="true" aria-labelledby="schedules-modal-title">
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import { fetchScripts, addScript, removeScript, type ScriptEntry } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
@@ -51,6 +52,7 @@ export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState<string | null>(null);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
const loadScripts = useCallback(async () => {
|
||||
try {
|
||||
@@ -170,10 +172,9 @@ export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose} data-testid="scripts-modal">
|
||||
<div className="modal-overlay open" {...overlayDismissProps} data-testid="scripts-modal">
|
||||
<div
|
||||
className="modal scripts-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Scripts"
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, Ntfy
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
|
||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
import "./SettingsModal.css";
|
||||
@@ -956,12 +957,7 @@ export function SettingsModal({
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
/**
|
||||
* Lane status types:
|
||||
@@ -4061,6 +4057,25 @@ export function SettingsModal({
|
||||
const authenticatedProviders = sortedProviders.filter(p => p.authenticated);
|
||||
const unauthenticatedProviders = sortedProviders.filter(p => !p.authenticated);
|
||||
|
||||
// Claude CLI lives in whichever bucket matches its current auth state
|
||||
// (Authenticated when signed in, Available otherwise) instead of
|
||||
// floating at the top of the panel — keeps the section that owns it
|
||||
// visually consistent with the rest of the providers.
|
||||
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli");
|
||||
const claudeCliCard = claudeCliProvider ? (
|
||||
<ClaudeCliProviderCard
|
||||
compact
|
||||
authenticated={claudeCliProvider.authenticated}
|
||||
onToggled={() => {
|
||||
void loadAuthStatus();
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
const showAuthenticatedGroup =
|
||||
authenticatedProviders.length > 0 || (claudeCliProvider?.authenticated ?? false);
|
||||
const showAvailableGroup =
|
||||
unauthenticatedProviders.length > 0 || (claudeCliProvider && !claudeCliProvider.authenticated);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4 className="settings-section-heading">Authentication</h4>
|
||||
@@ -4072,26 +4087,15 @@ export function SettingsModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="auth-panel-body">
|
||||
{cliAuthProviders.some((p) => p.id === "claude-cli") && (
|
||||
<ClaudeCliProviderCard
|
||||
compact
|
||||
authenticated={
|
||||
cliAuthProviders.find((p) => p.id === "claude-cli")
|
||||
?.authenticated ?? false
|
||||
}
|
||||
onToggled={() => {
|
||||
void loadAuthStatus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{authenticatedProviders.length === 0 && (
|
||||
{!showAuthenticatedGroup && (
|
||||
<div className="auth-section-hint">
|
||||
Sign in to at least one provider to get started with AI models.
|
||||
</div>
|
||||
)}
|
||||
{authenticatedProviders.length > 0 && (
|
||||
{showAuthenticatedGroup && (
|
||||
<div className="auth-provider-group">
|
||||
<div className="auth-group-label">Authenticated</div>
|
||||
{claudeCliProvider?.authenticated && claudeCliCard}
|
||||
{authenticatedProviders.map((provider) => (
|
||||
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
|
||||
<div className="auth-provider-header">
|
||||
@@ -4172,9 +4176,10 @@ export function SettingsModal({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{unauthenticatedProviders.length > 0 && (
|
||||
{showAvailableGroup && (
|
||||
<div className="auth-provider-group">
|
||||
<div className="auth-group-label">Available</div>
|
||||
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
|
||||
{unauthenticatedProviders.map((provider) => (
|
||||
<div key={provider.id} className="auth-provider-card">
|
||||
<div className="auth-provider-header">
|
||||
@@ -4275,7 +4280,7 @@ export function SettingsModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} role="dialog" aria-modal="true">
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
<div className="modal modal-lg settings-modal" ref={modalRef}>
|
||||
<div className="modal-header">
|
||||
<div className="settings-modal-heading">
|
||||
|
||||
@@ -2,6 +2,7 @@ import "./TaskDetailModal.css";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Bot, X, ChevronDown } from "lucide-react";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue } from "@fusion/core";
|
||||
@@ -794,12 +795,7 @@ export function TaskDetailModal({
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose, isEditing]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
const handleMove = useCallback(
|
||||
async (column: Column) => {
|
||||
@@ -1279,7 +1275,7 @@ export function TaskDetailModal({
|
||||
const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} role="dialog" aria-modal="true">
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
<div className="modal modal-lg task-detail-modal" ref={modalRef} onDragOver={handleDragOver} onDrop={handleDrop}>
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./WorkflowStepManager.css";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import type { WorkflowStep, WorkflowStepInput, WorkflowStepMode, WorkflowStepPhase } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -373,13 +374,13 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isEditing = isCreating || editingId !== null;
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose} data-testid="workflow-step-manager">
|
||||
<div className="modal-overlay open" {...overlayDismissProps} data-testid="workflow-step-manager">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="modal workflow-step-manager-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Workflow Steps"
|
||||
|
||||
38
packages/dashboard/app/hooks/useOverlayDismiss.ts
Normal file
38
packages/dashboard/app/hooks/useOverlayDismiss.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Returns props for a modal-overlay element that dismisses only when a real
|
||||
* overlay click happens — i.e. both mousedown AND mouseup land on the overlay
|
||||
* itself.
|
||||
*
|
||||
* This avoids a subtle dismiss-during-resize bug: when a user drags the
|
||||
* native CSS `resize: both` grip from inside a modal and releases the mouse
|
||||
* over the overlay, the synthesised click event targets the common ancestor
|
||||
* (the overlay). A naive `onClick` handler that checks `e.target === e.currentTarget`
|
||||
* is fooled and closes the modal mid-resize.
|
||||
*
|
||||
* Spread the returned props on the overlay element. The inner modal element
|
||||
* does NOT need to stopPropagation — mousedown on the modal sets the ref to
|
||||
* `false`, so the overlay's mouseup handler bails.
|
||||
*/
|
||||
export function useOverlayDismiss(onClose: () => void): {
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
onMouseUp: (e: React.MouseEvent) => void;
|
||||
} {
|
||||
const startedOnOverlayRef = useRef(false);
|
||||
|
||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
startedOnOverlayRef.current = e.target === e.currentTarget;
|
||||
}, []);
|
||||
|
||||
const onMouseUp = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const shouldClose = startedOnOverlayRef.current && e.target === e.currentTarget;
|
||||
startedOnOverlayRef.current = false;
|
||||
if (shouldClose) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return { onMouseDown, onMouseUp };
|
||||
}
|
||||
Reference in New Issue
Block a user