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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 { getStatusMeta, getUserMessageBg } from '../utils/status.js';
|
||||||
import { formatDuration, formatTime } from '../utils/formatting.js';
|
import { formatDuration, formatTime } from '../utils/formatting.js';
|
||||||
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
|
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
|
||||||
@@ -7,28 +7,28 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
|
|||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [inputFocused, setInputFocused] = useState(false);
|
const [inputFocused, setInputFocused] = useState(false);
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
const inputRef = useRef(null);
|
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 wasAtBottomRef = useRef(true);
|
||||||
const prevConversationLenRef = useRef(0);
|
const prevConversationLenRef = useRef(0);
|
||||||
const chatContainerRef = useRef(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (chatContainerRef.current) {
|
setClosing(false);
|
||||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
prevConversationLenRef.current = 0;
|
||||||
}
|
}, [session?.session_id]);
|
||||||
}, []);
|
|
||||||
|
// Animated close handler
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setClosing(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setClosing(false);
|
||||||
|
onClose();
|
||||||
|
}, 200);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
// Track scroll position
|
// Track scroll position
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -62,39 +62,42 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
|
|||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
inputRef.current.focus();
|
inputRef.current.focus();
|
||||||
}
|
}
|
||||||
}, [session]);
|
}, [session?.session_id]);
|
||||||
|
|
||||||
// Lock body scroll when modal is open
|
// Lock body scroll when modal is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!session) return;
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
};
|
};
|
||||||
}, []);
|
}, [!!session]);
|
||||||
|
|
||||||
// Handle keyboard events
|
// Handle keyboard events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!session) return;
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
// Escape closes modal
|
if (e.key === 'Escape') handleClose();
|
||||||
if (e.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('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) => {
|
const handleInputKeyDown = (e) => {
|
||||||
// Enter sends message (unless Shift+Enter for newline)
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send message
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const text = inputValue.trim();
|
const text = inputValue.trim();
|
||||||
if (!text || sending) return;
|
if (!text || sending) return;
|
||||||
@@ -105,7 +108,6 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
|
|||||||
await onSendMessage(session.session_id, text, true, optionCount);
|
await onSendMessage(session.session_id, text, true, optionCount);
|
||||||
}
|
}
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
// Refresh conversation after sending
|
|
||||||
if (onRefreshConversation) {
|
if (onRefreshConversation) {
|
||||||
await onRefreshConversation(session.session_id, session.project_dir, agent);
|
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`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm"
|
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 && onClose()}
|
onClick=${(e) => e.target === e.currentTarget && handleClose()}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="glass-panel flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-selection/80"
|
class="glass-panel flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-selection/80 ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
|
||||||
style=${{ borderLeftWidth: '3px', borderLeftColor: status.borderColor }}
|
style=${{ borderLeftWidth: '3px', borderLeftColor: status.borderColor }}
|
||||||
onClick=${(e) => e.stopPropagation()}
|
onClick=${(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -147,8 +151,8 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="ml-4 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-selection/70 text-dim transition-colors hover:border-done/35 hover:bg-done/10 hover:text-bright"
|
class="ml-4 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-selection/70 text-dim transition-colors duration-150 hover:border-done/35 hover:bg-done/10 hover:text-bright"
|
||||||
onClick=${onClose}
|
onClick=${handleClose}
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
@@ -159,14 +163,14 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
|
|||||||
<!-- Modal Content -->
|
<!-- Modal Content -->
|
||||||
<div ref=${chatContainerRef} class="flex-1 overflow-y-auto overflow-x-hidden bg-surface p-5">
|
<div ref=${chatContainerRef} class="flex-1 overflow-y-auto overflow-x-hidden bg-surface p-5">
|
||||||
${conversationLoading ? html`
|
${conversationLoading ? html`
|
||||||
<div class="flex items-center justify-center py-12">
|
<div class="flex items-center justify-center py-12 animate-fade-in-up">
|
||||||
<div class="font-mono text-dim">Loading conversation...</div>
|
<div class="font-mono text-dim">Loading conversation...</div>
|
||||||
</div>
|
</div>
|
||||||
` : conversation.length > 0 ? html`
|
` : displayMessages.length > 0 ? html`
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
${filterDisplayMessages(conversation).map((msg, i) => html`
|
${displayMessages.map((msg, i) => html`
|
||||||
<${MessageBubble}
|
<${MessageBubble}
|
||||||
key=${i}
|
key=${`${msg.role}-${msg.timestamp || i}`}
|
||||||
msg=${msg}
|
msg=${msg}
|
||||||
userBg=${getUserMessageBg(session.status)}
|
userBg=${getUserMessageBg(session.status)}
|
||||||
compact=${false}
|
compact=${false}
|
||||||
@@ -178,15 +182,6 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
|
|||||||
<p class="text-dim text-center py-12">No conversation messages</p>
|
<p class="text-dim text-center py-12">No conversation messages</p>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
${status.spinning && html`
|
|
||||||
<div class="shrink-0 flex items-center gap-2 border-t border-selection/40 bg-surface px-5 py-2">
|
|
||||||
<span class="h-2 w-2 rounded-full spinner-dot" style=${{ backgroundColor: status.borderColor, color: status.borderColor }}></span>
|
|
||||||
<span class="font-mono text-xs" style=${{ color: status.borderColor }}>Agent is working</span>
|
|
||||||
<span class="working-dots font-mono text-xs" style=${{ color: status.borderColor }}>
|
|
||||||
<span>.</span><span>.</span><span>.</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
|
|
||||||
<!-- Modal Footer -->
|
<!-- Modal Footer -->
|
||||||
<div class="border-t border-selection/70 bg-bg/55 p-4">
|
<div class="border-t border-selection/70 bg-bg/55 p-4">
|
||||||
@@ -209,12 +204,12 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
|
|||||||
onBlur=${() => setInputFocused(false)}
|
onBlur=${() => setInputFocused(false)}
|
||||||
placeholder=${hasPendingQuestions ? "Type your response..." : "Send a message..."}
|
placeholder=${hasPendingQuestions ? "Type your response..." : "Send a message..."}
|
||||||
rows="1"
|
rows="1"
|
||||||
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-4 py-2 text-sm text-fg placeholder:text-dim focus:outline-none"
|
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-4 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
||||||
style=${{ minHeight: '42px', maxHeight: '150px', borderColor: inputFocused ? status.borderColor : undefined }}
|
style=${{ minHeight: '42px', maxHeight: '150px', borderColor: inputFocused ? status.borderColor : undefined }}
|
||||||
disabled=${sending}
|
disabled=${sending}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="rounded-xl px-4 py-2 font-medium transition-all hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
class="rounded-xl px-4 py-2 font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
style=${{ backgroundColor: status.borderColor, color: '#0a0f18' }}
|
style=${{ backgroundColor: status.borderColor, color: '#0a0f18' }}
|
||||||
onClick=${handleSend}
|
onClick=${handleSend}
|
||||||
disabled=${sending || !inputValue.trim()}
|
disabled=${sending || !inputValue.trim()}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ body {
|
|||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.62;
|
opacity: 0.62;
|
||||||
transform: scale(1.12) translateY(-1px);
|
transform: scale(1.04) translateY(-1px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +99,40 @@ body {
|
|||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.working-dots span:nth-child(1) { animation: bounce-dot 1.2s ease-in-out infinite; }
|
.working-dots span:nth-child(1) { animation: bounce-dot 1.2s ease-out infinite; }
|
||||||
.working-dots span:nth-child(2) { animation: bounce-dot 1.2s ease-in-out 0.15s infinite; }
|
.working-dots span:nth-child(2) { animation: bounce-dot 1.2s ease-out 0.15s infinite; }
|
||||||
.working-dots span:nth-child(3) { animation: bounce-dot 1.2s ease-in-out 0.3s infinite; }
|
.working-dots span:nth-child(3) { animation: bounce-dot 1.2s ease-out 0.3s infinite; }
|
||||||
|
|
||||||
|
/* Modal entrance/exit animations */
|
||||||
|
@keyframes modalBackdropIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes modalBackdropOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes modalPanelIn {
|
||||||
|
from { opacity: 0; transform: scale(0.96) translateY(8px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes modalPanelOut {
|
||||||
|
from { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
to { opacity: 0; transform: scale(0.96) translateY(8px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop-in {
|
||||||
|
animation: modalBackdropIn 200ms ease-out;
|
||||||
|
}
|
||||||
|
.modal-backdrop-out {
|
||||||
|
animation: modalBackdropOut 200ms ease-in forwards;
|
||||||
|
}
|
||||||
|
.modal-panel-in {
|
||||||
|
animation: modalPanelIn 200ms ease-out;
|
||||||
|
}
|
||||||
|
.modal-panel-out {
|
||||||
|
animation: modalPanelOut 200ms ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
/* Accessibility: disable continuous animations for motion-sensitive users */
|
/* Accessibility: disable continuous animations for motion-sensitive users */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -114,6 +145,16 @@ body {
|
|||||||
.pulse-attention {
|
.pulse-attention {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
.modal-backdrop-in,
|
||||||
|
.modal-backdrop-out,
|
||||||
|
.modal-panel-in,
|
||||||
|
.modal-panel-out {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.animate-float,
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glass panel effect */
|
/* Glass panel effect */
|
||||||
|
|||||||
Reference in New Issue
Block a user