unify card/modal
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) -->
|
||||||
|
|||||||
@@ -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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
382
plans/card-modal-unification.md
Normal file
382
plans/card-modal-unification.md
Normal 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.
|
||||||
Reference in New Issue
Block a user