From 8578a19330ae5c11d4ab9ba0ebcb2f9124fd578e Mon Sep 17 00:00:00 2001 From: teernisse Date: Wed, 25 Feb 2026 16:31:51 -0500 Subject: [PATCH] feat(dashboard): add smooth modal entrance/exit animations Implements animated modal open/close with accessibility support: - Add closing state with 200ms exit animation before unmount - Refactor to React hooks-compliant structure (guards after hooks) - Add CSS keyframes for backdrop fade and panel scale+translate - Include prefers-reduced-motion media query to disable animations for users with vestibular sensitivities - Use handleClose callback wrapper for consistent animation behavior across Escape key, backdrop click, and close button The animations provide visual continuity without being distracting, and gracefully degrade to instant transitions when reduced motion is preferred. Co-Authored-By: Claude Opus 4.5 --- dashboard/components/Modal.js | 93 +++++++++++++++++------------------ dashboard/styles.css | 49 ++++++++++++++++-- 2 files changed, 89 insertions(+), 53 deletions(-) diff --git a/dashboard/components/Modal.js b/dashboard/components/Modal.js index 5547168..4a1bccd 100644 --- a/dashboard/components/Modal.js +++ b/dashboard/components/Modal.js @@ -1,4 +1,4 @@ -import { html, useState, useEffect, useRef } from '../lib/preact.js'; +import { html, useState, useEffect, useRef, useCallback } from '../lib/preact.js'; import { getStatusMeta, getUserMessageBg } from '../utils/status.js'; import { formatDuration, formatTime } from '../utils/formatting.js'; import { MessageBubble, filterDisplayMessages } from './MessageBubble.js'; @@ -7,28 +7,28 @@ export function Modal({ session, conversations, conversationLoading, onClose, on const [inputValue, setInputValue] = useState(''); const [sending, setSending] = useState(false); const [inputFocused, setInputFocused] = useState(false); + const [closing, setClosing] = useState(false); const inputRef = useRef(null); - - if (!session) return null; - - const conversation = conversations[session.session_id] || []; - const hasPendingQuestions = session.pending_questions && session.pending_questions.length > 0; - const optionCount = hasPendingQuestions ? session.pending_questions[0]?.options?.length || 0 : 0; - const status = getStatusMeta(session.status); - const agent = session.agent === 'codex' ? 'codex' : 'claude'; - const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude'; - - // Track if user has scrolled away from bottom const wasAtBottomRef = useRef(true); const prevConversationLenRef = useRef(0); const chatContainerRef = useRef(null); - // Initialize scroll position to bottom on mount (no animation) + const conversation = session ? (conversations[session.session_id] || []) : []; + + // Reset state when session changes useEffect(() => { - if (chatContainerRef.current) { - chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; - } - }, []); + setClosing(false); + prevConversationLenRef.current = 0; + }, [session?.session_id]); + + // Animated close handler + const handleClose = useCallback(() => { + setClosing(true); + setTimeout(() => { + setClosing(false); + onClose(); + }, 200); + }, [onClose]); // Track scroll position useEffect(() => { @@ -62,39 +62,42 @@ export function Modal({ session, conversations, conversationLoading, onClose, on if (inputRef.current) { inputRef.current.focus(); } - }, [session]); + }, [session?.session_id]); // Lock body scroll when modal is open useEffect(() => { + if (!session) return; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; - }, []); + }, [!!session]); // Handle keyboard events useEffect(() => { + if (!session) return; const handleKeyDown = (e) => { - // Escape closes modal - if (e.key === 'Escape') { - onClose(); - } + if (e.key === 'Escape') handleClose(); }; - document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [onClose]); + }, [!!session, handleClose]); + + if (!session) return null; + + const hasPendingQuestions = session.pending_questions && session.pending_questions.length > 0; + const optionCount = hasPendingQuestions ? session.pending_questions[0]?.options?.length || 0 : 0; + const status = getStatusMeta(session.status); + const agent = session.agent === 'codex' ? 'codex' : 'claude'; + const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude'; - // Handle input key events const handleInputKeyDown = (e) => { - // Enter sends message (unless Shift+Enter for newline) if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; - // Send message const handleSend = async () => { const text = inputValue.trim(); if (!text || sending) return; @@ -105,7 +108,6 @@ export function Modal({ session, conversations, conversationLoading, onClose, on await onSendMessage(session.session_id, text, true, optionCount); } setInputValue(''); - // Refresh conversation after sending if (onRefreshConversation) { await onRefreshConversation(session.session_id, session.project_dir, agent); } @@ -116,13 +118,15 @@ export function Modal({ session, conversations, conversationLoading, onClose, on } }; + const displayMessages = filterDisplayMessages(conversation); + return html`
e.target === e.currentTarget && onClose()} + class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm ${closing ? 'modal-backdrop-out' : 'modal-backdrop-in'}" + onClick=${(e) => e.target === e.currentTarget && handleClose()} >
e.stopPropagation()} > @@ -147,8 +151,8 @@ export function Modal({ session, conversations, conversationLoading, onClose, on