diff --git a/dashboard/components/App.js b/dashboard/components/App.js index 523a2e4..722a64c 100644 --- a/dashboard/components/App.js +++ b/dashboard/components/App.js @@ -5,6 +5,9 @@ import { Sidebar } from './Sidebar.js'; import { SessionCard } from './SessionCard.js'; import { Modal } from './Modal.js'; import { EmptyState } from './EmptyState.js'; +import { ToastContainer, trackError, clearErrorCount } from './Toast.js'; + +let optimisticMsgId = 0; export function App() { const [sessions, setSessions] = useState([]); @@ -15,8 +18,7 @@ export function App() { const [selectedProject, setSelectedProject] = useState(null); const [sseConnected, setSseConnected] = useState(false); - // Silent conversation refresh (no loading state, used for background polling) - // Defined early so fetchState can reference it + // Background conversation refresh with error tracking const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => { try { let url = API_CONVERSATION + encodeURIComponent(sessionId); @@ -25,14 +27,18 @@ export function App() { if (agent) params.set('agent', agent); if (params.toString()) url += '?' + params.toString(); const response = await fetch(url); - if (!response.ok) return; + if (!response.ok) { + trackError(`conversation-${sessionId}`, `Failed to fetch conversation (HTTP ${response.status})`); + return; + } const data = await response.json(); setConversations(prev => ({ ...prev, [sessionId]: data.messages || [] })); + clearErrorCount(`conversation-${sessionId}`); // Clear on success } catch (err) { - // Silent failure for background refresh + trackError(`conversation-${sessionId}`, `Failed to fetch conversation: ${err.message}`); } }, []); @@ -42,42 +48,52 @@ export function App() { // Refs for stable callback access (avoids recreation on state changes) const sessionsRef = useRef(sessions); const conversationsRef = useRef(conversations); + const modalSessionRef = useRef(null); sessionsRef.current = sessions; conversationsRef.current = conversations; // Apply state payload from polling or SSE stream const applyStateData = useCallback((data) => { const newSessions = data.sessions || []; + const newSessionIds = new Set(newSessions.map(s => s.session_id)); setSessions(newSessions); setError(null); // Update modalSession if it's still open (to get latest pending_questions, etc.) - let modalId = null; - setModalSession(prev => { - if (!prev) return null; - modalId = prev.session_id; - const updatedSession = newSessions.find(s => s.session_id === prev.session_id); - return updatedSession || prev; + const modalId = modalSessionRef.current; + if (modalId) { + const updatedSession = newSessions.find(s => s.session_id === modalId); + if (updatedSession) { + setModalSession(updatedSession); + } + } + + // Clean up conversation cache for sessions that no longer exist + setConversations(prev => { + const activeIds = Object.keys(prev).filter(id => newSessionIds.has(id)); + if (activeIds.length === Object.keys(prev).length) return prev; // No cleanup needed + const cleaned = {}; + for (const id of activeIds) { + cleaned[id] = prev[id]; + } + return cleaned; }); - // Only refresh conversations for sessions that have actually changed - // (compare last_event_at to avoid flooding the API) + // Refresh conversations for sessions that have actually changed + // Use conversation_mtime_ns for real-time updates (changes on every file write), + // falling back to last_event_at for sessions without mtime tracking const prevEventMap = lastEventAtRef.current; const nextEventMap = {}; for (const session of newSessions) { const id = session.session_id; - const newEventAt = session.last_event_at || ''; - nextEventMap[id] = newEventAt; + // Prefer mtime (changes on every write) over last_event_at (only on hook events) + const newKey = session.conversation_mtime_ns || session.last_event_at || ''; + nextEventMap[id] = newKey; - // Only refresh if: - // 1. Session is active/attention AND - // 2. last_event_at has actually changed OR it's the currently open modal - if (session.status === 'active' || session.status === 'needs_attention') { - const oldEventAt = prevEventMap[id] || ''; - if (newEventAt !== oldEventAt || id === modalId) { - refreshConversationSilent(session.session_id, session.project_dir, session.agent || 'claude'); - } + const oldKey = prevEventMap[id] || ''; + if (newKey !== oldKey) { + refreshConversationSilent(session.session_id, session.project_dir, session.agent || 'claude'); } } lastEventAtRef.current = nextEventMap; @@ -94,15 +110,16 @@ export function App() { } const data = await response.json(); applyStateData(data); + clearErrorCount('state-fetch'); } catch (err) { const msg = err.name === 'AbortError' ? 'Request timed out' : err.message; - console.error('Failed to fetch state:', msg); + trackError('state-fetch', `Failed to fetch state: ${msg}`); setError(msg); setLoading(false); } }, [applyStateData]); - // Fetch conversation for a session + // Fetch conversation for a session (explicit fetch, e.g., on modal open) const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', force = false) => { // Skip if already fetched and not forcing refresh if (!force && conversationsRef.current[sessionId]) return; @@ -115,7 +132,7 @@ export function App() { if (params.toString()) url += '?' + params.toString(); const response = await fetch(url); if (!response.ok) { - console.warn('Failed to fetch conversation for', sessionId); + trackError(`conversation-${sessionId}`, `Failed to fetch conversation (HTTP ${response.status})`); return; } const data = await response.json(); @@ -123,18 +140,33 @@ export function App() { ...prev, [sessionId]: data.messages || [] })); + clearErrorCount(`conversation-${sessionId}`); } catch (err) { - console.error('Error fetching conversation:', err); + trackError(`conversation-${sessionId}`, `Error fetching conversation: ${err.message}`); } }, []); - // Respond to a session's pending question + // Respond to a session's pending question with optimistic update const respondToSession = useCallback(async (sessionId, text, isFreeform = false, optionCount = 0) => { const payload = { text }; if (isFreeform) { payload.freeform = true; payload.optionCount = optionCount; } + + // Optimistic update: immediately show user's message + const optimisticMsg = { + id: `optimistic-${++optimisticMsgId}`, + role: 'user', + content: text, + timestamp: new Date().toISOString(), + _optimistic: true, // Flag for identification + }; + setConversations(prev => ({ + ...prev, + [sessionId]: [...(prev[sessionId] || []), optimisticMsg] + })); + try { const res = await fetch(API_RESPOND + encodeURIComponent(sessionId), { method: 'POST', @@ -142,20 +174,22 @@ export function App() { body: JSON.stringify(payload) }); const data = await res.json(); - if (data.ok) { - // 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'); - } + if (!data.ok) { + throw new Error(data.error || 'Failed to send response'); } + clearErrorCount(`respond-${sessionId}`); + // SSE will push state update when Claude processes the message, + // which triggers conversation refresh via applyStateData } catch (err) { - console.error('Error responding to session:', err); + // Remove optimistic message on failure + setConversations(prev => ({ + ...prev, + [sessionId]: (prev[sessionId] || []).filter(m => m !== optimisticMsg) + })); + trackError(`respond-${sessionId}`, `Failed to send message: ${err.message}`); throw err; // Re-throw so SimpleInput/QuestionBlock can catch and show error } - }, [fetchState, refreshConversationSilent]); + }, []); // Dismiss a session const dismissSession = useCallback(async (sessionId) => { @@ -167,9 +201,11 @@ export function App() { if (data.ok) { // Trigger refresh fetchState(); + } else { + trackError(`dismiss-${sessionId}`, `Failed to dismiss session: ${data.error || 'Unknown error'}`); } } catch (err) { - console.error('Error dismissing session:', err); + trackError(`dismiss-${sessionId}`, `Error dismissing session: ${err.message}`); } }, [fetchState]); @@ -185,7 +221,7 @@ export function App() { try { eventSource = new EventSource(API_STREAM); } catch (err) { - console.error('Failed to initialize EventSource:', err); + trackError('sse-init', `Failed to initialize EventSource: ${err.message}`); setSseConnected(false); reconnectTimer = setTimeout(connect, 2000); return; @@ -195,6 +231,9 @@ export function App() { if (stopped) return; setSseConnected(true); setError(null); + // Clear event cache on reconnect to force refresh of all conversations + // (handles updates missed during disconnect) + lastEventAtRef.current = {}; }); eventSource.addEventListener('state', (event) => { @@ -202,8 +241,9 @@ export function App() { try { const data = JSON.parse(event.data); applyStateData(data); + clearErrorCount('sse-parse'); } catch (err) { - console.error('Failed to parse SSE state payload:', err); + trackError('sse-parse', `Failed to parse SSE state payload: ${err.message}`); } }); @@ -258,6 +298,7 @@ export function App() { // Handle card click - open modal and fetch conversation if not cached const handleCardClick = useCallback(async (session) => { + modalSessionRef.current = session.session_id; setModalSession(session); // Fetch conversation if not already cached @@ -267,6 +308,7 @@ export function App() { }, [fetchConversation]); const handleCloseModal = useCallback(() => { + modalSessionRef.current = null; setModalSession(null); }, []); @@ -380,5 +422,7 @@ export function App() { onRespond=${respondToSession} onDismiss=${dismissSession} /> + + <${ToastContainer} /> `; } diff --git a/dashboard/components/ChatMessages.js b/dashboard/components/ChatMessages.js index 6023dab..4fc795d 100644 --- a/dashboard/components/ChatMessages.js +++ b/dashboard/components/ChatMessages.js @@ -2,6 +2,13 @@ import { html } from '../lib/preact.js'; import { getUserMessageBg } from '../utils/status.js'; import { MessageBubble, filterDisplayMessages } from './MessageBubble.js'; +function getMessageKey(msg, index) { + // Server-assigned ID (preferred) + if (msg.id) return msg.id; + // Fallback: role + timestamp + index (for legacy/edge cases) + return `${msg.role}-${msg.timestamp || ''}-${index}`; +} + export function ChatMessages({ messages, status, limit = 20 }) { const userBgClass = getUserMessageBg(status); @@ -21,7 +28,7 @@ export function ChatMessages({ messages, status, limit = 20 }) {
${displayMessages.map((msg, i) => html` <${MessageBubble} - key=${`${msg.role}-${msg.timestamp || (offset + i)}`} + key=${getMessageKey(msg, offset + i)} msg=${msg} userBg=${userBgClass} compact=${true} diff --git a/dashboard/components/SessionCard.js b/dashboard/components/SessionCard.js index 280ffcd..e609b4c 100644 --- a/dashboard/components/SessionCard.js +++ b/dashboard/components/SessionCard.js @@ -20,33 +20,60 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio }, [session.session_id, session.project_dir, agent, conversation, onFetchConversation]); const chatPaneRef = useRef(null); - const wasAtBottomRef = useRef(true); + const stickyToBottomRef = useRef(true); // Start in "sticky" mode + const scrollUpAccumulatorRef = useRef(0); // Track cumulative scroll-up distance const prevConversationLenRef = useRef(0); - // Track scroll position for smart scrolling + // Track user intent via wheel events (only fires from actual user scrolling) useEffect(() => { const el = chatPaneRef.current; if (!el) return; - const handleScroll = () => { - const threshold = 50; - wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < threshold; + const handleWheel = (e) => { + // User scrolling up - accumulate distance before disabling sticky + if (e.deltaY < 0) { + scrollUpAccumulatorRef.current += Math.abs(e.deltaY); + // Only disable sticky mode after scrolling up ~50px (meaningful intent) + if (scrollUpAccumulatorRef.current > 50) { + stickyToBottomRef.current = false; + } + } + + // User scrolling down - reset accumulator and check if near bottom + if (e.deltaY > 0) { + scrollUpAccumulatorRef.current = 0; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceFromBottom < 100) { + stickyToBottomRef.current = true; + } + } }; - el.addEventListener('scroll', handleScroll); - return () => el.removeEventListener('scroll', handleScroll); + el.addEventListener('wheel', handleWheel, { passive: true }); + return () => el.removeEventListener('wheel', handleWheel); }, []); - // Smart scroll: only scroll to bottom on new messages if user was already at bottom + // Auto-scroll when conversation changes useEffect(() => { const el = chatPaneRef.current; if (!el || !conversation) return; - const hasNewMessages = conversation.length > prevConversationLenRef.current; - prevConversationLenRef.current = conversation.length; + const prevLen = prevConversationLenRef.current; + const currLen = conversation.length; + const hasNewMessages = currLen > prevLen; + const isFirstLoad = prevLen === 0 && currLen > 0; - if (hasNewMessages && wasAtBottomRef.current) { - el.scrollTop = el.scrollHeight; + // Check if user just submitted (always scroll for their own messages) + const lastMsg = conversation[currLen - 1]; + const userJustSubmitted = hasNewMessages && lastMsg?.role === 'user'; + + prevConversationLenRef.current = currLen; + + // Auto-scroll if in sticky mode, first load, or user just submitted + if (isFirstLoad || userJustSubmitted || (hasNewMessages && stickyToBottomRef.current)) { + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); } }, [conversation]); @@ -118,7 +145,7 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
-
+
${hasQuestions ? html` <${QuestionBlock} questions=${session.pending_questions} diff --git a/dashboard/components/SimpleInput.js b/dashboard/components/SimpleInput.js index 3d29d86..6943f9f 100644 --- a/dashboard/components/SimpleInput.js +++ b/dashboard/components/SimpleInput.js @@ -1,4 +1,4 @@ -import { html, useState } from '../lib/preact.js'; +import { html, useState, useRef } from '../lib/preact.js'; import { getStatusMeta } from '../utils/status.js'; export function SimpleInput({ sessionId, status, onRespond }) { @@ -6,6 +6,7 @@ export function SimpleInput({ sessionId, status, onRespond }) { const [focused, setFocused] = useState(false); const [sending, setSending] = useState(false); const [error, setError] = useState(null); + const textareaRef = useRef(null); const meta = getStatusMeta(status); const handleSubmit = async (e) => { @@ -22,6 +23,11 @@ export function SimpleInput({ sessionId, status, onRespond }) { console.error('SimpleInput send error:', err); } finally { setSending(false); + // Refocus the textarea after submission + // Use setTimeout to ensure React has re-rendered with disabled=false + setTimeout(() => { + textareaRef.current?.focus(); + }, 0); } } }; @@ -35,6 +41,7 @@ export function SimpleInput({ sessionId, status, onRespond }) { `}