feat(dashboard): improve real-time updates and scroll behavior

Major UX improvements to conversation display and state management.

Scroll behavior (SessionCard.js):
- Replace scroll-position tracking with wheel-event intent detection
- Accumulate scroll-up distance before disabling sticky mode (50px threshold)
- Re-enable sticky on scroll-down when near bottom (100px threshold)
- Always scroll to bottom on first load or user's own message submission
- Use requestAnimationFrame for smooth scroll positioning

Optimistic updates (App.js):
- Immediately show user messages before API confirmation
- Remove optimistic message on send failure
- Eliminates perceived latency when sending responses

Error tracking integration (App.js):
- Wire up trackError/clearErrorCount for API operations
- Track: state fetch, conversation fetch, respond, dismiss, SSE init/parse
- Clear error counts on successful operations
- Clear SSE event cache on reconnect to force refresh

Conversation management (App.js):
- Use mtime_ns (preferred) or last_event_at for change detection
- Clean up conversation cache when sessions are dismissed
- Add modalSessionRef for stable reference across renders

Message stability (ChatMessages.js):
- Prefer server-assigned message IDs for React keys
- Fallback to role+timestamp+index for legacy messages

Input UX (SimpleInput.js):
- Auto-refocus textarea after successful submission
- Use setTimeout to ensure React has re-rendered first

Sorting simplification (status.js):
- Remove status-based group/session reordering
- Return groups in API order (server handles sorting)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 15:24:06 -05:00
parent 3dc10aa060
commit b9c1bd6ff1
5 changed files with 142 additions and 78 deletions

View File

@@ -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} />
`;
}