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:
@@ -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} />
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
</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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user