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

@@ -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}