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,44 +48,54 @@ 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) {
const oldKey = prevEventMap[id] || '';
if (newKey !== oldKey) {
refreshConversationSilent(session.session_id, session.project_dir, session.agent || 'claude');
}
}
}
lastEventAtRef.current = nextEventMap;
setLoading(false);
@@ -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} />
`;
}

View File

@@ -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 }) {
<div class="space-y-2.5">
${displayMessages.map((msg, i) => html`
<${MessageBubble}
key=${`${msg.role}-${msg.timestamp || (offset + i)}`}
key=${getMessageKey(msg, offset + i)}
msg=${msg}
userBg=${userBgClass}
compact=${true}

View File

@@ -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) {
// 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
</div>
<!-- Card Footer (Input or Questions) -->
<div class="shrink-0 border-t border-selection/70 bg-bg/55 p-4 ${hasQuestions ? 'max-h-[300px] overflow-y-auto' : ''}">
<div class="shrink-0 border-t border-selection/70 bg-bg/55 p-4">
${hasQuestions ? html`
<${QuestionBlock}
questions=${session.pending_questions}

View File

@@ -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 }) {
`}
<div class="flex items-end gap-2.5">
<textarea
ref=${textareaRef}
value=${text}
onInput=${(e) => {
setText(e.target.value);

View File

@@ -74,27 +74,6 @@ export function groupSessionsByProject(sessions) {
groups.get(key).sessions.push(session);
}
const result = Array.from(groups.values());
// Sort groups: most urgent status first, then most recent activity
result.sort((a, b) => {
const aWorst = Math.min(...a.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
const bWorst = Math.min(...b.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
if (aWorst !== bWorst) return aWorst - bWorst;
const aRecent = Math.max(...a.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
const bRecent = Math.max(...b.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
return bRecent - aRecent;
});
// Sort sessions within each group: urgent first, then most recent
for (const group of result) {
group.sessions.sort((a, b) => {
const aPri = STATUS_PRIORITY[a.status] ?? 99;
const bPri = STATUS_PRIORITY[b.status] ?? 99;
if (aPri !== bPri) return aPri - bPri;
return (b.last_event_at || '').localeCompare(a.last_event_at || '');
});
}
return result;
// Return groups in API order (no status-based reordering)
return Array.from(groups.values());
}