From 5049709c5dce8f9c38f27f3f8ed5cd3178e26315 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:38:16 +0800 Subject: [PATCH] fix(ui): use custom ConfirmDialog for deletions to prevent input blocking on Windows (#282) --- src/components/layout/Sidebar.tsx | 29 ++++++++-- src/components/ui/confirm-dialog.tsx | 84 ++++++++++++++++++++++++++++ src/pages/Channels/index.tsx | 35 ++++++++++-- src/pages/Chat/ChatInput.tsx | 7 +++ src/pages/Cron/index.tsx | 34 +++++++---- 5 files changed, 166 insertions(+), 23 deletions(-) create mode 100644 src/components/ui/confirm-dialog.tsx diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e963a46ae..933703169 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -3,6 +3,7 @@ * Navigation sidebar with menu items. * No longer fixed - sits inside the flex layout below the title bar. */ +import { useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { Home, @@ -22,6 +23,7 @@ import { useSettingsStore } from '@/stores/settings'; import { useChatStore } from '@/stores/chat'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useTranslation } from 'react-i18next'; interface NavItemProps { @@ -104,6 +106,7 @@ export function Sidebar() { }; const { t } = useTranslation(); + const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null); const navItems = [ { to: '/cron', icon: , label: t('sidebar.cronTasks') }, @@ -170,12 +173,12 @@ export function Sidebar() { {!s.key.endsWith(':main') && ( + + { + if (!sessionToDelete) return; + await deleteSession(sessionToDelete.key); + if (currentSessionKey === sessionToDelete.key) navigate('/'); + setSessionToDelete(null); + }} + onCancel={() => setSessionToDelete(null)} + /> ); } diff --git a/src/components/ui/confirm-dialog.tsx b/src/components/ui/confirm-dialog.tsx new file mode 100644 index 000000000..061a51bad --- /dev/null +++ b/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,84 @@ +/** + * ConfirmDialog - In-DOM confirmation dialog (replaces window.confirm) + * Keeps focus within the renderer to avoid Windows focus loss after native dialogs. + */ +import { useEffect, useRef } from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +interface ConfirmDialogProps { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'default' | 'destructive'; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmDialog({ + open, + title, + message, + confirmLabel = 'OK', + cancelLabel = 'Cancel', + variant = 'default', + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const cancelRef = useRef(null); + + useEffect(() => { + if (open && cancelRef.current) { + cancelRef.current.focus(); + } + }, [open]); + + if (!open) return null; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + return ( +
+
+

+ {title} +

+

{message}

+
+ + +
+
+
+ ); +} diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index b63162286..16b53c979 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -2,7 +2,7 @@ * Channels Page * Manage messaging channel connections with configuration UI */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Plus, Radio, @@ -28,6 +28,7 @@ import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useChannelsStore } from '@/stores/channels'; import { useGatewayStore } from '@/stores/gateway'; import { StatusBadge, type Status } from '@/components/common/StatusBadge'; @@ -53,6 +54,7 @@ export function Channels() { const [showAddDialog, setShowAddDialog] = useState(false); const [selectedChannelType, setSelectedChannelType] = useState(null); const [configuredTypes, setConfiguredTypes] = useState([]); + const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null); // Fetch channels on mount useEffect(() => { @@ -204,11 +206,7 @@ export function Channels() { { - if (confirm(t('deleteConfirm'))) { - deleteChannel(channel.id); - } - }} + onDelete={() => setChannelToDelete({ id: channel.id })} /> ))} @@ -281,6 +279,22 @@ export function Channels() { }} /> )} + + { + if (channelToDelete) { + await deleteChannel(channelToDelete.id); + setChannelToDelete(null); + } + }} + onCancel={() => setChannelToDelete(null)} + /> ); } @@ -350,6 +364,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded const [validating, setValidating] = useState(false); const [loadingConfig, setLoadingConfig] = useState(false); const [isExistingConfig, setIsExistingConfig] = useState(false); + const firstInputRef = useRef(null); const [validationResult, setValidationResult] = useState<{ valid: boolean; errors: string[]; @@ -403,6 +418,13 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded return () => { cancelled = true; }; }, [selectedType]); + // Focus first input when form is ready (avoids Windows focus loss after native dialogs) + useEffect(() => { + if (selectedType && !loadingConfig && firstInputRef.current) { + firstInputRef.current.focus(); + } + }, [selectedType, loadingConfig]); + // Listen for WhatsApp QR events useEffect(() => { if (selectedType !== 'whatsapp') return; @@ -753,6 +775,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
{ + if (!disabled && textareaRef.current) { + textareaRef.current.focus(); + } + }, []); + // ── File staging via native dialog ───────────────────────────── const pickFiles = useCallback(async () => { diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index d2604ed59..e6a55a2e5 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -28,6 +28,7 @@ import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useCronStore } from '@/stores/cron'; import { useGatewayStore } from '@/stores/gateway'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; @@ -318,9 +319,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard }; const handleDelete = () => { - if (confirm(t('card.deleteConfirm'))) { - onDelete(); - } + onDelete(); }; return ( @@ -442,6 +441,7 @@ export function Cron() { const gatewayStatus = useGatewayStore((state) => state.status); const [showDialog, setShowDialog] = useState(false); const [editingJob, setEditingJob] = useState(); + const [jobToDelete, setJobToDelete] = useState<{ id: string } | null>(null); const isGatewayRunning = gatewayStatus.state === 'running'; @@ -474,14 +474,7 @@ export function Cron() { } }, [toggleJob, t]); - const handleDelete = useCallback(async (id: string) => { - try { - await deleteJob(id); - toast.success(t('toast.deleted')); - } catch { - toast.error(t('toast.failedDelete')); - } - }, [deleteJob, t]); + if (loading) { return ( @@ -629,7 +622,7 @@ export function Cron() { setEditingJob(job); setShowDialog(true); }} - onDelete={() => handleDelete(job.id)} + onDelete={() => setJobToDelete({ id: job.id })} onTrigger={() => triggerJob(job.id)} /> ))} @@ -647,6 +640,23 @@ export function Cron() { onSave={handleSave} /> )} + + { + if (jobToDelete) { + await deleteJob(jobToDelete.id); + setJobToDelete(null); + toast.success(t('toast.deleted')); + } + }} + onCancel={() => setJobToDelete(null)} + />
); }