unify card/modal

This commit is contained in:
teernisse
2026-02-26 10:20:26 -05:00
parent 31862f3a40
commit fa1ad4b22b
7 changed files with 522 additions and 238 deletions

View File

@@ -10,7 +10,6 @@ export function App() {
const [sessions, setSessions] = useState([]); const [sessions, setSessions] = useState([]);
const [modalSession, setModalSession] = useState(null); const [modalSession, setModalSession] = useState(null);
const [conversations, setConversations] = useState({}); const [conversations, setConversations] = useState({});
const [conversationLoading, setConversationLoading] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [selectedProject, setSelectedProject] = useState(null); const [selectedProject, setSelectedProject] = useState(null);
@@ -40,6 +39,12 @@ export function App() {
// Track last_event_at for each session to detect actual changes // Track last_event_at for each session to detect actual changes
const lastEventAtRef = useRef({}); const lastEventAtRef = useRef({});
// Refs for stable callback access (avoids recreation on state changes)
const sessionsRef = useRef(sessions);
const conversationsRef = useRef(conversations);
sessionsRef.current = sessions;
conversationsRef.current = conversations;
// Apply state payload from polling or SSE stream // Apply state payload from polling or SSE stream
const applyStateData = useCallback((data) => { const applyStateData = useCallback((data) => {
const newSessions = data.sessions || []; const newSessions = data.sessions || [];
@@ -98,13 +103,9 @@ export function App() {
}, [applyStateData]); }, [applyStateData]);
// Fetch conversation for a session // Fetch conversation for a session
const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', showLoading = false, force = false) => { const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', force = false) => {
// Skip if already fetched and not forcing refresh // Skip if already fetched and not forcing refresh
if (!force && conversations[sessionId]) return; if (!force && conversationsRef.current[sessionId]) return;
if (showLoading) {
setConversationLoading(true);
}
try { try {
let url = API_CONVERSATION + encodeURIComponent(sessionId); let url = API_CONVERSATION + encodeURIComponent(sessionId);
@@ -124,12 +125,8 @@ export function App() {
})); }));
} catch (err) { } catch (err) {
console.error('Error fetching conversation:', err); console.error('Error fetching conversation:', err);
} finally {
if (showLoading) {
setConversationLoading(false);
} }
} }, []);
}, [conversations]);
// Respond to a session's pending question // Respond to a session's pending question
const respondToSession = useCallback(async (sessionId, text, isFreeform = false, optionCount = 0) => { const respondToSession = useCallback(async (sessionId, text, isFreeform = false, optionCount = 0) => {
@@ -146,13 +143,19 @@ export function App() {
}); });
const data = await res.json(); const data = await res.json();
if (data.ok) { if (data.ok) {
// Trigger refresh // Trigger state refresh
fetchState(); fetchState();
// Refresh conversation for immediate feedback
const session = sessionsRef.current.find(s => s.session_id === sessionId);
if (session) {
await refreshConversationSilent(sessionId, session.project_dir, session.agent || 'claude');
}
} }
} catch (err) { } catch (err) {
console.error('Error responding to session:', err); console.error('Error responding to session:', err);
throw err; // Re-throw so SimpleInput/QuestionBlock can catch and show error
} }
}, [fetchState]); }, [fetchState, refreshConversationSilent]);
// Dismiss a session // Dismiss a session
const dismissSession = useCallback(async (sessionId) => { const dismissSession = useCallback(async (sessionId) => {
@@ -258,20 +261,9 @@ export function App() {
setModalSession(session); setModalSession(session);
// Fetch conversation if not already cached // Fetch conversation if not already cached
if (!conversations[session.session_id]) { if (!conversationsRef.current[session.session_id]) {
await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude', true); await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude');
} }
}, [conversations, fetchConversation]);
// Refresh conversation (force re-fetch, used after sending messages)
const refreshConversation = useCallback(async (sessionId, projectDir, agent = 'claude') => {
// Force refresh by clearing cache first
setConversations(prev => {
const updated = { ...prev };
delete updated[sessionId];
return updated;
});
await fetchConversation(sessionId, projectDir, agent, false, true);
}, [fetchConversation]); }, [fetchConversation]);
const handleCloseModal = useCallback(() => { const handleCloseModal = useCallback(() => {
@@ -383,10 +375,10 @@ export function App() {
<${Modal} <${Modal}
session=${modalSession} session=${modalSession}
conversations=${conversations} conversations=${conversations}
conversationLoading=${conversationLoading}
onClose=${handleCloseModal} onClose=${handleCloseModal}
onSendMessage=${respondToSession} onFetchConversation=${fetchConversation}
onRefreshConversation=${refreshConversation} onRespond=${respondToSession}
onDismiss=${dismissSession}
/> />
`; `;
} }

View File

@@ -2,19 +2,19 @@ import { html } from '../lib/preact.js';
import { getUserMessageBg } from '../utils/status.js'; import { getUserMessageBg } from '../utils/status.js';
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js'; import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
export function ChatMessages({ messages, status }) { export function ChatMessages({ messages, status, limit = 20 }) {
const userBgClass = getUserMessageBg(status); const userBgClass = getUserMessageBg(status);
if (!messages || messages.length === 0) { if (!messages || messages.length === 0) {
return html` return html`
<div class="flex h-full items-center justify-center rounded-xl border border-dashed border-selection/70 bg-bg/30 px-4 text-center text-sm text-dim"> <div class="flex h-full items-center justify-center rounded-xl border border-dashed border-selection/70 bg-bg/30 px-4 text-center text-sm text-dim">
No messages yet No messages to show
</div> </div>
`; `;
} }
const allDisplayMessages = filterDisplayMessages(messages); const allDisplayMessages = filterDisplayMessages(messages);
const displayMessages = allDisplayMessages.slice(-20); const displayMessages = limit ? allDisplayMessages.slice(-limit) : allDisplayMessages;
const offset = allDisplayMessages.length - displayMessages.length; const offset = allDisplayMessages.length - displayMessages.length;
return html` return html`

View File

@@ -1,24 +1,12 @@
import { html, useState, useEffect, useRef, useCallback } from '../lib/preact.js'; import { html, useState, useEffect, useCallback } from '../lib/preact.js';
import { getStatusMeta, getUserMessageBg } from '../utils/status.js'; import { SessionCard } from './SessionCard.js';
import { formatDuration, formatTime } from '../utils/formatting.js';
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
export function Modal({ session, conversations, conversationLoading, onClose, onSendMessage, onRefreshConversation }) { export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) {
const [inputValue, setInputValue] = useState('');
const [sending, setSending] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
const [closing, setClosing] = 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 closing state when session changes
// Reset state when session changes
useEffect(() => { useEffect(() => {
setClosing(false); setClosing(false);
prevConversationLenRef.current = 0;
}, [session?.session_id]); }, [session?.session_id]);
// Animated close handler // Animated close handler
@@ -30,40 +18,6 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
}, 200); }, 200);
}, [onClose]); }, [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 // Lock body scroll when modal is open
useEffect(() => { useEffect(() => {
if (!session) return; if (!session) return;
@@ -71,9 +25,9 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
return () => { return () => {
document.body.style.overflow = ''; document.body.style.overflow = '';
}; };
}, [!!session]); }, [session?.session_id]);
// Handle keyboard events // Handle escape key
useEffect(() => { useEffect(() => {
if (!session) return; if (!session) return;
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
@@ -81,146 +35,26 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown);
}, [!!session, handleClose]); }, [session?.session_id, handleClose]);
if (!session) return null; if (!session) return null;
const hasPendingQuestions = session.pending_questions && session.pending_questions.length > 0; const conversation = conversations[session.session_id] || [];
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` return html`
<div <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'}" 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()} onClick=${(e) => e.target === e.currentTarget && handleClose()}
> >
<div <div class=${closing ? 'modal-panel-out' : 'modal-panel-in'} onClick=${(e) => e.stopPropagation()}>
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'}" <${SessionCard}
style=${{ borderLeftWidth: '3px', borderLeftColor: status.borderColor }} session=${session}
onClick=${(e) => e.stopPropagation()} conversation=${conversation}
> onFetchConversation=${onFetchConversation}
<!-- Modal Header --> onRespond=${onRespond}
<div class="flex items-center justify-between border-b px-5 py-4 ${agentHeaderClass}"> onDismiss=${onDismiss}
<div class="flex-1 min-w-0"> enlarged=${true}
<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>
</div> </div>
`; `;

View File

@@ -5,6 +5,8 @@ import { OptionButton } from './OptionButton.js';
export function QuestionBlock({ questions, sessionId, status, onRespond }) { export function QuestionBlock({ questions, sessionId, status, onRespond }) {
const [freeformText, setFreeformText] = useState(''); const [freeformText, setFreeformText] = useState('');
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
const meta = getStatusMeta(status); const meta = getStatusMeta(status);
if (!questions || questions.length === 0) return null; if (!questions || questions.length === 0) return null;
@@ -14,21 +16,45 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
const remainingCount = questions.length - 1; const remainingCount = questions.length - 1;
const options = question.options || []; const options = question.options || [];
const handleOptionClick = (optionLabel) => { const handleOptionClick = async (optionLabel) => {
onRespond(sessionId, optionLabel, false, options.length); if (sending) return;
setSending(true);
setError(null);
try {
await onRespond(sessionId, optionLabel, false, options.length);
} catch (err) {
setError('Failed to send response');
console.error('QuestionBlock option error:', err);
} finally {
setSending(false);
}
}; };
const handleFreeformSubmit = (e) => { const handleFreeformSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (freeformText.trim()) { if (freeformText.trim() && !sending) {
onRespond(sessionId, freeformText.trim(), true, options.length); setSending(true);
setError(null);
try {
await onRespond(sessionId, freeformText.trim(), true, options.length);
setFreeformText(''); setFreeformText('');
} catch (err) {
setError('Failed to send response');
console.error('QuestionBlock freeform error:', err);
} finally {
setSending(false);
}
} }
}; };
return html` return html`
<div class="space-y-3" onClick=${(e) => e.stopPropagation()}> <div class="space-y-3" onClick=${(e) => e.stopPropagation()}>
${error && html`
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
${error}
</div>
`}
<!-- Question Header Badge --> <!-- Question Header Badge -->
${question.header && html` ${question.header && html`
<span class="inline-flex rounded-full border px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] ${meta.badge}"> <span class="inline-flex rounded-full border px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] ${meta.badge}">
@@ -75,13 +101,15 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
rows="1" rows="1"
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none" class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }} style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
disabled=${sending}
/> />
<button <button
type="submit" type="submit"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110" class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }} style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
disabled=${sending || !freeformText.trim()}
> >
Send ${sending ? 'Sending...' : 'Send'}
</button> </button>
</form> </form>

View File

@@ -5,7 +5,7 @@ import { ChatMessages } from './ChatMessages.js';
import { QuestionBlock } from './QuestionBlock.js'; import { QuestionBlock } from './QuestionBlock.js';
import { SimpleInput } from './SimpleInput.js'; import { SimpleInput } from './SimpleInput.js';
export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss }) { export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss, enlarged = false }) {
const hasQuestions = session.pending_questions && session.pending_questions.length > 0; const hasQuestions = session.pending_questions && session.pending_questions.length > 0;
const statusMeta = getStatusMeta(session.status); const statusMeta = getStatusMeta(session.status);
const agent = session.agent === 'codex' ? 'codex' : 'claude'; const agent = session.agent === 'codex' ? 'codex' : 'claude';
@@ -20,23 +20,51 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
}, [session.session_id, session.project_dir, agent, conversation, onFetchConversation]); }, [session.session_id, session.project_dir, agent, conversation, onFetchConversation]);
const chatPaneRef = useRef(null); const chatPaneRef = useRef(null);
const wasAtBottomRef = useRef(true);
const prevConversationLenRef = useRef(0);
// Scroll chat pane to bottom when conversation loads or updates // Track scroll position for smart scrolling
useEffect(() => { useEffect(() => {
const el = chatPaneRef.current; const el = chatPaneRef.current;
if (el) el.scrollTop = el.scrollHeight; if (!el) return;
const handleScroll = () => {
const threshold = 50;
wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
};
el.addEventListener('scroll', handleScroll);
return () => el.removeEventListener('scroll', handleScroll);
}, []);
// Smart scroll: only scroll to bottom on new messages if user was already at bottom
useEffect(() => {
const el = chatPaneRef.current;
if (!el || !conversation) return;
const hasNewMessages = conversation.length > prevConversationLenRef.current;
prevConversationLenRef.current = conversation.length;
if (hasNewMessages && wasAtBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [conversation]); }, [conversation]);
const handleDismissClick = (e) => { const handleDismissClick = (e) => {
e.stopPropagation(); e.stopPropagation();
onDismiss(session.session_id); if (onDismiss) onDismiss(session.session_id);
}; };
// Container classes differ based on enlarged mode
const containerClasses = enlarged
? 'glass-panel flex w-full max-w-[90vw] max-h-[90vh] flex-col overflow-hidden rounded-2xl border border-selection/80'
: 'glass-panel flex h-[850px] max-h-[850px] w-[600px] cursor-pointer flex-col overflow-hidden rounded-xl border border-selection/70 transition-[border-color,box-shadow] duration-200 hover:border-starting/35 hover:shadow-panel';
return html` return html`
<div <div
class="glass-panel flex h-[850px] max-h-[850px] w-[600px] cursor-pointer flex-col overflow-hidden rounded-xl border border-selection/70 transition-[border-color,box-shadow] duration-200 hover:border-starting/35 hover:shadow-panel" class=${containerClasses}
style=${{ borderLeftWidth: '3px', borderLeftColor: statusMeta.borderColor }} style=${{ borderLeftWidth: '3px', borderLeftColor: statusMeta.borderColor }}
onClick=${() => onClick(session)} onClick=${enlarged ? undefined : () => onClick && onClick(session)}
> >
<!-- Card Header --> <!-- Card Header -->
<div class="shrink-0 border-b px-4 py-3 ${agentHeaderClass}"> <div class="shrink-0 border-b px-4 py-3 ${agentHeaderClass}">
@@ -86,7 +114,7 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
<!-- Card Content Area (Chat) --> <!-- Card Content Area (Chat) -->
<div ref=${chatPaneRef} class="flex-1 min-h-0 overflow-y-auto bg-surface px-4 py-4"> <div ref=${chatPaneRef} class="flex-1 min-h-0 overflow-y-auto bg-surface px-4 py-4">
<${ChatMessages} messages=${conversation || []} status=${session.status} /> <${ChatMessages} messages=${conversation || []} status=${session.status} limit=${enlarged ? null : 20} />
</div> </div>
<!-- Card Footer (Input or Questions) --> <!-- Card Footer (Input or Questions) -->

View File

@@ -4,19 +4,36 @@ import { getStatusMeta } from '../utils/status.js';
export function SimpleInput({ sessionId, status, onRespond }) { export function SimpleInput({ sessionId, status, onRespond }) {
const [text, setText] = useState(''); const [text, setText] = useState('');
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
const meta = getStatusMeta(status); const meta = getStatusMeta(status);
const handleSubmit = (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (text.trim()) { if (text.trim() && !sending) {
onRespond(sessionId, text.trim(), true, 0); setSending(true);
setError(null);
try {
await onRespond(sessionId, text.trim(), true, 0);
setText(''); setText('');
} catch (err) {
setError('Failed to send message');
console.error('SimpleInput send error:', err);
} finally {
setSending(false);
}
} }
}; };
return html` return html`
<form onSubmit=${handleSubmit} class="flex items-end gap-2.5" onClick=${(e) => e.stopPropagation()}> <form onSubmit=${handleSubmit} class="flex flex-col gap-2" onClick=${(e) => e.stopPropagation()}>
${error && html`
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
${error}
</div>
`}
<div class="flex items-end gap-2.5">
<textarea <textarea
value=${text} value=${text}
onInput=${(e) => { onInput=${(e) => {
@@ -36,14 +53,17 @@ export function SimpleInput({ sessionId, status, onRespond }) {
rows="1" rows="1"
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none" class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }} style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
disabled=${sending}
/> />
<button <button
type="submit" type="submit"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110" class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }} style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
disabled=${sending || !text.trim()}
> >
Send ${sending ? 'Sending...' : 'Send'}
</button> </button>
</div>
</form> </form>
`; `;
} }

View File

@@ -0,0 +1,382 @@
# Card/Modal Unification Plan
**Status:** Implemented
**Date:** 2026-02-26
**Author:** Claude + Taylor
---
## Executive Summary
Unify SessionCard and Modal into a single component with an `enlarged` prop, eliminating 165 lines of duplicated code and ensuring feature parity across both views.
---
## 1. Problem Statement
### 1.1 What's Broken
The AMC dashboard displays agent sessions as cards in a grid. Clicking a card opens a "modal" for a larger, focused view. These two views evolved independently, creating:
| Issue | Impact |
|-------|--------|
| **Duplicated rendering logic** | Modal.js reimplemented header, chat, input from scratch (227 lines) |
| **Feature drift** | Card had context usage display; modal didn't. Modal had timestamps; card didn't. |
| **Maintenance burden** | Every card change required parallel modal changes (often forgotten) |
| **Inconsistent UX** | Users see different information depending on view |
### 1.2 Why This Matters
The modal's purpose is simple: **show an enlarged view with more screen space for content**. It should not be a separate implementation with different features. Users clicking a card expect to see *the same thing, bigger* — not a different interface.
### 1.3 Root Cause
The modal was originally built as a separate component because it needed:
- Backdrop blur with click-outside-to-close
- Escape key handling
- Body scroll lock
- Entrance/exit animations
These concerns led developers to copy-paste card internals into the modal rather than compose them.
---
## 2. Goals and Non-Goals
### 2.1 Goals
1. **Zero duplicated rendering code** — Single source of truth for how sessions display
2. **Automatic feature parity** — Any card change propagates to modal without extra work
3. **Preserve modal behaviors** — Backdrop, escape key, animations, scroll lock
4. **Add missing features to both views** — Smart scroll, sending state feedback
### 2.2 Non-Goals
- Changing the visual design of either view
- Adding new features beyond parity + smart scroll + sending state
- Refactoring other components
---
## 3. User Workflows
### 3.1 Current User Journey
```
User sees session cards in grid
├─► Card shows: status, agent, cwd, context usage, last 20 messages, input
└─► User clicks card
└─► Modal opens with DIFFERENT layout:
- Combined status badge (dot inside)
- No context usage
- All messages with timestamps
- Different input implementation
- Keyboard hints shown
```
### 3.2 Target User Journey
```
User sees session cards in grid
├─► Card shows: status, agent, cwd, context usage, last 20 messages, input
└─► User clicks card
└─► Modal opens with SAME card, just bigger:
- Identical header layout
- Context usage visible
- All messages (not limited to 20)
- Same input components
- Same everything, more space
```
### 3.3 User Benefits
| Benefit | Rationale |
|---------|-----------|
| **Cognitive consistency** | Same information architecture in both views reduces learning curve |
| **Trust** | No features "hiding" in one view or the other |
| **Predictability** | Click = zoom, not "different interface" |
---
## 4. Design Decisions
### 4.1 Architecture: Shared Component with Prop
**Decision:** Add `enlarged` prop to SessionCard. Modal renders `<SessionCard enlarged={true} />`.
**Alternatives Considered:**
| Alternative | Rejected Because |
|-------------|------------------|
| Modal wraps Card with CSS transform | Breaks layout, accessibility issues, can't change message limit |
| Higher-order component | Unnecessary complexity for single boolean difference |
| Render props pattern | Overkill, harder to read |
| Separate "CardContent" extracted | Still requires prop to control limit, might as well be on SessionCard |
**Rationale:** A single boolean prop is the simplest solution that achieves all goals. The `enlarged` prop controls exactly two things: container sizing and message limit. Everything else is identical.
---
### 4.2 Message Limit: Card 20, Enlarged All
**Decision:** Card shows last 20 messages. Enlarged view shows all.
**Rationale:**
- Cards in a grid need bounded height for visual consistency
- 20 messages is enough context without overwhelming the card
- Enlarged view exists specifically to see more — no artificial limit makes sense
- Implementation: `limit` prop on ChatMessages (20 default, null for unlimited)
---
### 4.3 Header Layout: Keep Card's Multi-Row Style
**Decision:** Use the card's multi-row header layout for both views.
**Modal had:** Single row with combined status badge (dot inside badge)
**Card had:** Multi-row with separate dot, status badge, agent badge, cwd badge, context usage
**Rationale:**
- Card layout shows more information (context usage was missing from modal)
- Multi-row handles overflow gracefully with `flex-wrap`
- Consistent with the "modal = bigger card" philosophy
---
### 4.4 Spacing: Keep Tighter (Card Style)
**Decision:** Use card's tighter spacing (`px-4 py-3`, `space-y-2.5`) for both views.
**Modal had:** Roomier spacing (`px-5 py-4`, `space-y-4`)
**Rationale:**
- Tighter spacing is more information-dense
- Enlarged view gains space from larger container, not wider margins
- Consistent visual rhythm between views
---
### 4.5 Empty State Text: "No messages to show"
**Decision:** Standardize on "No messages to show" (neither original).
**Card had:** "No messages yet"
**Modal had:** "No conversation messages"
**Rationale:** "No messages to show" is neutral and accurate — doesn't imply timing ("yet") or specific terminology ("conversation").
---
## 5. Implementation Details
### 5.1 SessionCard.js Changes
```
BEFORE: SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss })
AFTER: SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss, enlarged = false })
```
**New behaviors controlled by `enlarged`:**
| Aspect | `enlarged=false` (card) | `enlarged=true` (modal) |
|--------|-------------------------|-------------------------|
| Container classes | `h-[850px] w-[600px] cursor-pointer hover:...` | `max-w-5xl max-h-[90vh]` |
| Click handler | `onClick(session)` | `undefined` (no-op) |
| Message limit | 20 | null (all) |
**New feature: Smart scroll tracking**
```js
// Track if user is at bottom
const wasAtBottomRef = useRef(true);
// On scroll, update tracking
const handleScroll = () => {
wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
};
// On new messages, only scroll if user was at bottom
if (hasNewMessages && wasAtBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
```
**Rationale:** Users reading history shouldn't be yanked to bottom when new messages arrive. Only auto-scroll if they were already at the bottom (watching live updates).
---
### 5.2 Modal.js Changes
**Before:** 227 lines reimplementing header, chat, input, scroll, state management
**After:** 62 lines — backdrop wrapper only
```js
export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) {
// Closing animation state
// Body scroll lock
// Escape key handler
return html`
<div class="backdrop...">
<${SessionCard}
session=${session}
conversation=${conversations[session.session_id] || []}
onFetchConversation=${onFetchConversation}
onRespond=${onRespond}
onDismiss=${onDismiss}
onClick=${() => {}}
enlarged=${true}
/>
</div>
`;
}
```
**Preserved behaviors:**
- Backdrop blur (`bg-[#02050d]/84 backdrop-blur-sm`)
- Click outside to close
- Escape key handler
- Body scroll lock (`document.body.style.overflow = 'hidden'`)
- Entrance/exit animations (CSS classes)
---
### 5.3 ChatMessages.js Changes
```
BEFORE: ChatMessages({ messages, status })
AFTER: ChatMessages({ messages, status, limit = 20 })
```
**Logic change:**
```js
// Before: always slice to 20
const displayMessages = allDisplayMessages.slice(-20);
// After: respect limit prop
const displayMessages = limit ? allDisplayMessages.slice(-limit) : allDisplayMessages;
```
---
### 5.4 SimpleInput.js / QuestionBlock.js Changes
**New feature: Sending state feedback**
```js
const [sending, setSending] = useState(false);
const handleSubmit = async (e) => {
if (sending) return;
setSending(true);
try {
await onRespond(...);
} finally {
setSending(false);
}
};
// In render:
<button disabled=${sending}>
${sending ? 'Sending...' : 'Send'}
</button>
```
**Rationale:** Users need feedback that their message is being sent. Without this, they might click multiple times or think the UI is broken.
---
### 5.5 App.js Changes
**Removed (unused after refactor):**
- `conversationLoading` state — was only passed to Modal
- `refreshConversation` callback — was only used by Modal's custom send handler
**Modified:**
- `respondToSession` now refreshes conversation immediately after successful send
- Modal receives same props as SessionCard (onRespond, onFetchConversation, onDismiss)
---
## 6. Dependency Graph
```
App.js
├─► SessionCard (in grid)
│ ├─► ChatMessages (limit=20)
│ │ └─► MessageBubble
│ ├─► QuestionBlock (with sending state)
│ │ └─► OptionButton
│ └─► SimpleInput (with sending state)
└─► Modal (backdrop wrapper)
└─► SessionCard (enlarged=true)
├─► ChatMessages (limit=null)
│ └─► MessageBubble
├─► QuestionBlock (with sending state)
│ └─► OptionButton
└─► SimpleInput (with sending state)
```
**Key insight:** Modal no longer has its own rendering tree. It delegates entirely to SessionCard.
---
## 7. Metrics
| Metric | Before | After | Change |
|--------|--------|-------|--------|
| Modal.js lines | 227 | 62 | -73% |
| Total duplicated code | ~180 lines | 0 | -100% |
| Features requiring dual maintenance | All | None | -100% |
| Prop surface area (Modal) | 6 custom | 6 same as card | Aligned |
---
## 8. Verification Checklist
- [x] Card displays: status dot, status badge, agent badge, cwd, context usage, messages, input
- [x] Modal displays: identical to card, just larger
- [x] Card limits to 20 messages
- [x] Modal shows all messages
- [x] Smart scroll works in both views
- [x] "Sending..." feedback works in both views
- [x] Escape closes modal
- [x] Click outside closes modal
- [x] Entrance/exit animations work
- [x] Body scroll locked when modal open
---
## 9. Future Considerations
### 9.1 Potential Enhancements
| Enhancement | Rationale | Blocked By |
|-------------|-----------|------------|
| Keyboard navigation in card grid | Accessibility | None |
| Resize modal dynamically | User preference | None |
| Pin modal to side (split view) | Power user workflow | Design decision needed |
### 9.2 Maintenance Notes
- **Any SessionCard change** automatically applies to modal view
- **To add modal-only behavior**: Check `enlarged` prop (but avoid this — keep views identical)
- **To change message limit**: Modify the `limit` prop value in SessionCard's ChatMessages call
---
## 10. Lessons Learned
1. **Composition > Duplication** — When two UIs show the same data, compose them from shared components
2. **Props for variations** — A single boolean prop is often sufficient for "same thing, different context"
3. **Identify the actual differences** — Modal needed only: backdrop, escape key, scroll lock, animations. Everything else was false complexity.
4. **Feature drift is inevitable** — Duplicated code guarantees divergence over time. Only shared code stays in sync.