Visual feedback when an agent is actively processing:
1. **Spinner on status dots** (SessionCard.js, Modal.js)
- Status dot gets a spinning ring animation when session is active/starting
- Uses CSS border trick with transparent borders except top
2. **Working indicator in chat** (ChatMessages.js, Modal.js)
- Shows at bottom of conversation when agent is working
- Bouncing dots animation ("...") next to "Agent is working" text
- Only visible for active/starting statuses
3. **CSS animations** (styles.css)
- spin-ring: 0.8s rotation for the status dot border
- bounce-dot: staggered vertical bounce for the working dots
4. **Status metadata** (status.js)
- Added `spinning: true` flag for active and starting statuses
- Used by components to conditionally render spinner elements
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
233 lines
9.5 KiB
JavaScript
233 lines
9.5 KiB
JavaScript
import { html, useState, useEffect, useRef } 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 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)
|
|
useEffect(() => {
|
|
if (chatContainerRef.current) {
|
|
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
|
}
|
|
}, []);
|
|
|
|
// 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]);
|
|
|
|
// Lock body scroll when modal is open
|
|
useEffect(() => {
|
|
document.body.style.overflow = 'hidden';
|
|
return () => {
|
|
document.body.style.overflow = '';
|
|
};
|
|
}, []);
|
|
|
|
// Handle keyboard events
|
|
useEffect(() => {
|
|
const handleKeyDown = (e) => {
|
|
// Escape closes modal
|
|
if (e.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [onClose]);
|
|
|
|
// 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;
|
|
|
|
setSending(true);
|
|
try {
|
|
if (onSendMessage) {
|
|
await onSendMessage(session.session_id, text, true, optionCount);
|
|
}
|
|
setInputValue('');
|
|
// Refresh conversation after sending
|
|
if (onRefreshConversation) {
|
|
await onRefreshConversation(session.session_id, session.project_dir, agent);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to send message:', err);
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
return html`
|
|
<div
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm"
|
|
onClick=${(e) => e.target === e.currentTarget && onClose()}
|
|
>
|
|
<div
|
|
class="glass-panel flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-selection/80"
|
|
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 hover:border-done/35 hover:bg-done/10 hover:text-bright"
|
|
onClick=${onClose}
|
|
>
|
|
<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">
|
|
<div class="font-mono text-dim">Loading conversation...</div>
|
|
</div>
|
|
` : conversation.length > 0 ? html`
|
|
<div class="space-y-4">
|
|
${filterDisplayMessages(conversation).map((msg, i) => html`
|
|
<${MessageBubble}
|
|
key=${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>
|
|
${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 -->
|
|
<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 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-all 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>
|
|
`;
|
|
}
|