From fa1ad4b22b768c888eab0f69f2a5eda823b7f438 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 10:20:26 -0500 Subject: [PATCH] unify card/modal --- dashboard/components/App.js | 52 ++-- dashboard/components/ChatMessages.js | 6 +- dashboard/components/Modal.js | 200 ++------------ dashboard/components/QuestionBlock.js | 44 ++- dashboard/components/SessionCard.js | 42 ++- dashboard/components/SimpleInput.js | 34 ++- plans/card-modal-unification.md | 382 ++++++++++++++++++++++++++ 7 files changed, 522 insertions(+), 238 deletions(-) create mode 100644 plans/card-modal-unification.md diff --git a/dashboard/components/App.js b/dashboard/components/App.js index ac271d1..523a2e4 100644 --- a/dashboard/components/App.js +++ b/dashboard/components/App.js @@ -10,7 +10,6 @@ export function App() { const [sessions, setSessions] = useState([]); const [modalSession, setModalSession] = useState(null); const [conversations, setConversations] = useState({}); - const [conversationLoading, setConversationLoading] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = 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 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 const applyStateData = useCallback((data) => { const newSessions = data.sessions || []; @@ -98,13 +103,9 @@ export function App() { }, [applyStateData]); // 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 - if (!force && conversations[sessionId]) return; - - if (showLoading) { - setConversationLoading(true); - } + if (!force && conversationsRef.current[sessionId]) return; try { let url = API_CONVERSATION + encodeURIComponent(sessionId); @@ -124,12 +125,8 @@ export function App() { })); } catch (err) { console.error('Error fetching conversation:', err); - } finally { - if (showLoading) { - setConversationLoading(false); - } } - }, [conversations]); + }, []); // Respond to a session's pending question const respondToSession = useCallback(async (sessionId, text, isFreeform = false, optionCount = 0) => { @@ -146,13 +143,19 @@ export function App() { }); const data = await res.json(); if (data.ok) { - // Trigger refresh + // Trigger state refresh 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) { 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 const dismissSession = useCallback(async (sessionId) => { @@ -258,20 +261,9 @@ export function App() { setModalSession(session); // Fetch conversation if not already cached - if (!conversations[session.session_id]) { - await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude', true); + if (!conversationsRef.current[session.session_id]) { + 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]); const handleCloseModal = useCallback(() => { @@ -383,10 +375,10 @@ export function App() { <${Modal} session=${modalSession} conversations=${conversations} - conversationLoading=${conversationLoading} onClose=${handleCloseModal} - onSendMessage=${respondToSession} - onRefreshConversation=${refreshConversation} + onFetchConversation=${fetchConversation} + onRespond=${respondToSession} + onDismiss=${dismissSession} /> `; } diff --git a/dashboard/components/ChatMessages.js b/dashboard/components/ChatMessages.js index 16f03b2..6023dab 100644 --- a/dashboard/components/ChatMessages.js +++ b/dashboard/components/ChatMessages.js @@ -2,19 +2,19 @@ import { html } from '../lib/preact.js'; import { getUserMessageBg } from '../utils/status.js'; import { MessageBubble, filterDisplayMessages } from './MessageBubble.js'; -export function ChatMessages({ messages, status }) { +export function ChatMessages({ messages, status, limit = 20 }) { const userBgClass = getUserMessageBg(status); if (!messages || messages.length === 0) { return html`
- No messages yet + No messages to show
`; } const allDisplayMessages = filterDisplayMessages(messages); - const displayMessages = allDisplayMessages.slice(-20); + const displayMessages = limit ? allDisplayMessages.slice(-limit) : allDisplayMessages; const offset = allDisplayMessages.length - displayMessages.length; return html` diff --git a/dashboard/components/Modal.js b/dashboard/components/Modal.js index 4a1bccd..0a1bc8f 100644 --- a/dashboard/components/Modal.js +++ b/dashboard/components/Modal.js @@ -1,24 +1,12 @@ -import { html, useState, useEffect, useRef, useCallback } from '../lib/preact.js'; -import { getStatusMeta, getUserMessageBg } from '../utils/status.js'; -import { formatDuration, formatTime } from '../utils/formatting.js'; -import { MessageBubble, filterDisplayMessages } from './MessageBubble.js'; +import { html, useState, useEffect, useCallback } from '../lib/preact.js'; +import { SessionCard } from './SessionCard.js'; -export function Modal({ session, conversations, conversationLoading, onClose, onSendMessage, onRefreshConversation }) { - const [inputValue, setInputValue] = useState(''); - const [sending, setSending] = useState(false); - const [inputFocused, setInputFocused] = useState(false); +export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) { const [closing, setClosing] = useState(false); - const inputRef = useRef(null); - const wasAtBottomRef = useRef(true); - const prevConversationLenRef = useRef(0); - const chatContainerRef = useRef(null); - const conversation = session ? (conversations[session.session_id] || []) : []; - - // Reset state when session changes + // Reset closing state when session changes useEffect(() => { setClosing(false); - prevConversationLenRef.current = 0; }, [session?.session_id]); // Animated close handler @@ -30,40 +18,6 @@ export function Modal({ session, conversations, conversationLoading, onClose, on }, 200); }, [onClose]); - // Track scroll position - useEffect(() => { - const container = chatContainerRef.current; - if (!container) return; - - const handleScroll = () => { - const threshold = 50; - wasAtBottomRef.current = container.scrollHeight - container.scrollTop - container.clientHeight < threshold; - }; - - container.addEventListener('scroll', handleScroll); - return () => container.removeEventListener('scroll', handleScroll); - }, []); - - // Only scroll to bottom on NEW messages, and only if user was already at bottom - useEffect(() => { - const container = chatContainerRef.current; - if (!container || !conversation) return; - - const hasNewMessages = conversation.length > prevConversationLenRef.current; - prevConversationLenRef.current = conversation.length; - - if (hasNewMessages && wasAtBottomRef.current) { - container.scrollTop = container.scrollHeight; - } - }, [conversation]); - - // Focus input when modal opens - useEffect(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, [session?.session_id]); - // Lock body scroll when modal is open useEffect(() => { if (!session) return; @@ -71,9 +25,9 @@ export function Modal({ session, conversations, conversationLoading, onClose, on return () => { document.body.style.overflow = ''; }; - }, [!!session]); + }, [session?.session_id]); - // Handle keyboard events + // Handle escape key useEffect(() => { if (!session) return; const handleKeyDown = (e) => { @@ -81,146 +35,26 @@ export function Modal({ session, conversations, conversationLoading, onClose, on }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [!!session, handleClose]); + }, [session?.session_id, handleClose]); if (!session) return null; - const hasPendingQuestions = session.pending_questions && session.pending_questions.length > 0; - const optionCount = hasPendingQuestions ? session.pending_questions[0]?.options?.length || 0 : 0; - const status = getStatusMeta(session.status); - const agent = session.agent === 'codex' ? 'codex' : 'claude'; - const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude'; - - const handleInputKeyDown = (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }; - - const handleSend = async () => { - const text = inputValue.trim(); - if (!text || sending) return; - - setSending(true); - try { - if (onSendMessage) { - await onSendMessage(session.session_id, text, true, optionCount); - } - setInputValue(''); - if (onRefreshConversation) { - await onRefreshConversation(session.session_id, session.project_dir, agent); - } - } catch (err) { - console.error('Failed to send message:', err); - } finally { - setSending(false); - } - }; - - const displayMessages = filterDisplayMessages(conversation); + const conversation = conversations[session.session_id] || []; return html`
e.target === e.currentTarget && handleClose()} > -
e.stopPropagation()} - > - -
-
-
-

${session.project || session.name || session.session_id}

-
- - ${status.label} -
- - ${agent} - -
-
- ${session.cwd || 'No working directory'} - ${session.started_at && html` - Running ${formatDuration(session.started_at)} - `} -
-
- -
- - -
- ${conversationLoading ? html` -
-
Loading conversation...
-
- ` : displayMessages.length > 0 ? html` -
- ${displayMessages.map((msg, i) => html` - <${MessageBubble} - key=${`${msg.role}-${msg.timestamp || i}`} - msg=${msg} - userBg=${getUserMessageBg(session.status)} - compact=${false} - formatTime=${formatTime} - /> - `)} -
- ` : html` -

No conversation messages

- `} -
- - -
- ${hasPendingQuestions && html` -
- Agent is waiting for a response -
- `} -
-