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:
gsxdsm
2026-04-27 19:36:24 -07:00
parent 95566795ea
commit b196ab42b4
9 changed files with 87 additions and 44 deletions

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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"

View File

@@ -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">

View File

@@ -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">

View File

@@ -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"

View 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 };
}