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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user