Files
amc/dashboard/components/Modal.js
teernisse da08d7a588 refactor(dashboard): extract modular Preact component structure
Replace the monolithic single-file dashboards (dashboard.html,
dashboard-preact.html) with a proper modular directory structure:

  dashboard/
    index.html              - Entry point, loads main.js
    main.js                 - App bootstrap, mounts <App> to #root
    styles.css              - Global styles (dark theme, typography)
    components/
      App.js                - Root component, state management, polling
      Header.js             - Top bar with refresh/timing info
      Sidebar.js            - Project tree navigation
      SessionCard.js        - Individual session card with status/actions
      SessionGroup.js       - Group sessions by project path
      Modal.js              - Full conversation viewer overlay
      ChatMessages.js       - Message list with role styling
      MessageBubble.js      - Individual message with markdown
      QuestionBlock.js      - User question input with quick options
      EmptyState.js         - "No sessions" placeholder
      OptionButton.js       - Quick response button component
      SimpleInput.js        - Text input with send button
    lib/
      preact.js             - Preact + htm ESM bundle (CDN shim)
      markdown.js           - Lightweight markdown-to-HTML renderer
    utils/
      api.js                - fetch wrappers for /api/* endpoints
      formatting.js         - Time formatting, truncation helpers
      status.js             - Session status logic, action availability

This structure enables:
- Browser-native ES modules (no build step required)
- Component reuse and isolation
- Easier styling and theming
- IDE support for component navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 15:01:47 -05:00

224 lines
8.9 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}"></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>
<!-- 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>
`;
}