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>
228 lines
9.2 KiB
JavaScript
228 lines
9.2 KiB
JavaScript
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';
|
|
|
|
export function Modal({ session, conversations, conversationLoading, onClose, onSendMessage, onRefreshConversation }) {
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
const [inputFocused, setInputFocused] = useState(false);
|
|
const [closing, setClosing] = useState(false);
|
|
const inputRef = useRef(null);
|
|
const wasAtBottomRef = useRef(true);
|
|
const prevConversationLenRef = useRef(0);
|
|
const chatContainerRef = useRef(null);
|
|
|
|
const conversation = session ? (conversations[session.session_id] || []) : [];
|
|
|
|
// Reset state when session changes
|
|
useEffect(() => {
|
|
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(() => {
|
|
const container = chatContainerRef.current;
|
|
if (!container) return;
|
|
|
|
const handleScroll = () => {
|
|
const threshold = 50;
|
|
wasAtBottomRef.current = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
|
};
|
|
|
|
container.addEventListener('scroll', handleScroll);
|
|
return () => container.removeEventListener('scroll', handleScroll);
|
|
}, []);
|
|
|
|
// Only scroll to bottom on NEW messages, and only if user was already at bottom
|
|
useEffect(() => {
|
|
const container = chatContainerRef.current;
|
|
if (!container || !conversation) return;
|
|
|
|
const hasNewMessages = conversation.length > prevConversationLenRef.current;
|
|
prevConversationLenRef.current = conversation.length;
|
|
|
|
if (hasNewMessages && wasAtBottomRef.current) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}, [conversation]);
|
|
|
|
// Focus input when modal opens
|
|
useEffect(() => {
|
|
if (inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, [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) => {
|
|
if (e.key === 'Escape') handleClose();
|
|
};
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [!!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';
|
|
|
|
const handleInputKeyDown = (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
const handleSend = async () => {
|
|
const text = inputValue.trim();
|
|
if (!text || sending) return;
|
|
|
|
setSending(true);
|
|
try {
|
|
if (onSendMessage) {
|
|
await onSendMessage(session.session_id, text, true, optionCount);
|
|
}
|
|
setInputValue('');
|
|
if (onRefreshConversation) {
|
|
await onRefreshConversation(session.session_id, session.project_dir, agent);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to send message:', err);
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
const displayMessages = filterDisplayMessages(conversation);
|
|
|
|
return html`
|
|
<div
|
|
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()}
|
|
>
|
|
<div
|
|
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 }}
|
|
onClick=${(e) => e.stopPropagation()}
|
|
>
|
|
<!-- Modal Header -->
|
|
<div class="flex items-center justify-between border-b px-5 py-4 ${agentHeaderClass}">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="mb-1 flex items-center gap-3">
|
|
<h2 class="truncate font-display text-xl font-semibold text-bright">${session.project || session.name || session.session_id}</h2>
|
|
<div class="flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${status.badge}">
|
|
<span class="h-2 w-2 rounded-full ${status.dot} ${status.spinning ? 'spinner-dot' : ''}" style=${{ color: status.borderColor }}></span>
|
|
<span class="font-mono uppercase tracking-[0.14em]">${status.label}</span>
|
|
</div>
|
|
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${agent === 'codex' ? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300' : 'border-violet-400/45 bg-violet-500/14 text-violet-300'}">
|
|
${agent}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-4 text-sm text-dim">
|
|
<span class="truncate font-mono">${session.cwd || 'No working directory'}</span>
|
|
${session.started_at && html`
|
|
<span class="flex-shrink-0 font-mono">Running ${formatDuration(session.started_at)}</span>
|
|
`}
|
|
</div>
|
|
</div>
|
|
<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 duration-150 hover:border-done/35 hover:bg-done/10 hover:text-bright"
|
|
onClick=${handleClose}
|
|
>
|
|
<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"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Modal Content -->
|
|
<div ref=${chatContainerRef} class="flex-1 overflow-y-auto overflow-x-hidden bg-surface p-5">
|
|
${conversationLoading ? html`
|
|
<div class="flex items-center justify-center py-12 animate-fade-in-up">
|
|
<div class="font-mono text-dim">Loading conversation...</div>
|
|
</div>
|
|
` : displayMessages.length > 0 ? html`
|
|
<div class="space-y-4">
|
|
${displayMessages.map((msg, i) => html`
|
|
<${MessageBubble}
|
|
key=${`${msg.role}-${msg.timestamp || i}`}
|
|
msg=${msg}
|
|
userBg=${getUserMessageBg(session.status)}
|
|
compact=${false}
|
|
formatTime=${formatTime}
|
|
/>
|
|
`)}
|
|
</div>
|
|
` : html`
|
|
<p class="text-dim text-center py-12">No conversation messages</p>
|
|
`}
|
|
</div>
|
|
|
|
<!-- Modal Footer -->
|
|
<div class="border-t border-selection/70 bg-bg/55 p-4">
|
|
${hasPendingQuestions && html`
|
|
<div class="mb-3 rounded-xl border border-attention/40 bg-attention/12 px-3 py-2 text-sm text-attention">
|
|
Agent is waiting for a response
|
|
</div>
|
|
`}
|
|
<div class="flex items-end gap-2.5">
|
|
<textarea
|
|
ref=${inputRef}
|
|
value=${inputValue}
|
|
onInput=${(e) => {
|
|
setInputValue(e.target.value);
|
|
e.target.style.height = 'auto';
|
|
e.target.style.height = Math.min(e.target.scrollHeight, 150) + 'px';
|
|
}}
|
|
onKeyDown=${handleInputKeyDown}
|
|
onFocus=${() => setInputFocused(true)}
|
|
onBlur=${() => setInputFocused(false)}
|
|
placeholder=${hasPendingQuestions ? "Type your response..." : "Send a message..."}
|
|
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 transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
|
style=${{ minHeight: '42px', maxHeight: '150px', borderColor: inputFocused ? status.borderColor : undefined }}
|
|
disabled=${sending}
|
|
/>
|
|
<button
|
|
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' }}
|
|
onClick=${handleSend}
|
|
disabled=${sending || !inputValue.trim()}
|
|
>
|
|
${sending ? 'Sending...' : 'Send'}
|
|
</button>
|
|
</div>
|
|
<div class="mt-2 font-mono text-label text-dim">
|
|
Press Enter to send, Shift+Enter for new line, Escape to close
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|