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 { SessionCard } from './SessionCard.js';
|
||||||
import { Modal } from './Modal.js';
|
import { Modal } from './Modal.js';
|
||||||
import { EmptyState } from './EmptyState.js';
|
import { EmptyState } from './EmptyState.js';
|
||||||
|
import { ToastContainer, trackError, clearErrorCount } from './Toast.js';
|
||||||
|
|
||||||
|
let optimisticMsgId = 0;
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [sessions, setSessions] = useState([]);
|
const [sessions, setSessions] = useState([]);
|
||||||
@@ -15,8 +18,7 @@ export function App() {
|
|||||||
const [selectedProject, setSelectedProject] = useState(null);
|
const [selectedProject, setSelectedProject] = useState(null);
|
||||||
const [sseConnected, setSseConnected] = useState(false);
|
const [sseConnected, setSseConnected] = useState(false);
|
||||||
|
|
||||||
// Silent conversation refresh (no loading state, used for background polling)
|
// Background conversation refresh with error tracking
|
||||||
// Defined early so fetchState can reference it
|
|
||||||
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
|
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
|
||||||
try {
|
try {
|
||||||
let url = API_CONVERSATION + encodeURIComponent(sessionId);
|
let url = API_CONVERSATION + encodeURIComponent(sessionId);
|
||||||
@@ -25,14 +27,18 @@ export function App() {
|
|||||||
if (agent) params.set('agent', agent);
|
if (agent) params.set('agent', agent);
|
||||||
if (params.toString()) url += '?' + params.toString();
|
if (params.toString()) url += '?' + params.toString();
|
||||||
const response = await fetch(url);
|
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();
|
const data = await response.json();
|
||||||
setConversations(prev => ({
|
setConversations(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[sessionId]: data.messages || []
|
[sessionId]: data.messages || []
|
||||||
}));
|
}));
|
||||||
|
clearErrorCount(`conversation-${sessionId}`); // Clear on success
|
||||||
} catch (err) {
|
} 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)
|
// Refs for stable callback access (avoids recreation on state changes)
|
||||||
const sessionsRef = useRef(sessions);
|
const sessionsRef = useRef(sessions);
|
||||||
const conversationsRef = useRef(conversations);
|
const conversationsRef = useRef(conversations);
|
||||||
|
const modalSessionRef = useRef(null);
|
||||||
sessionsRef.current = sessions;
|
sessionsRef.current = sessions;
|
||||||
conversationsRef.current = conversations;
|
conversationsRef.current = conversations;
|
||||||
|
|
||||||
// Apply state payload from polling or SSE stream
|
// Apply state payload from polling or SSE stream
|
||||||
const applyStateData = useCallback((data) => {
|
const applyStateData = useCallback((data) => {
|
||||||
const newSessions = data.sessions || [];
|
const newSessions = data.sessions || [];
|
||||||
|
const newSessionIds = new Set(newSessions.map(s => s.session_id));
|
||||||
setSessions(newSessions);
|
setSessions(newSessions);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Update modalSession if it's still open (to get latest pending_questions, etc.)
|
// Update modalSession if it's still open (to get latest pending_questions, etc.)
|
||||||
let modalId = null;
|
const modalId = modalSessionRef.current;
|
||||||
setModalSession(prev => {
|
if (modalId) {
|
||||||
if (!prev) return null;
|
const updatedSession = newSessions.find(s => s.session_id === modalId);
|
||||||
modalId = prev.session_id;
|
if (updatedSession) {
|
||||||
const updatedSession = newSessions.find(s => s.session_id === prev.session_id);
|
setModalSession(updatedSession);
|
||||||
return updatedSession || prev;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Refresh conversations for sessions that have actually changed
|
||||||
// (compare last_event_at to avoid flooding the API)
|
// 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 prevEventMap = lastEventAtRef.current;
|
||||||
const nextEventMap = {};
|
const nextEventMap = {};
|
||||||
|
|
||||||
for (const session of newSessions) {
|
for (const session of newSessions) {
|
||||||
const id = session.session_id;
|
const id = session.session_id;
|
||||||
const newEventAt = session.last_event_at || '';
|
// Prefer mtime (changes on every write) over last_event_at (only on hook events)
|
||||||
nextEventMap[id] = newEventAt;
|
const newKey = session.conversation_mtime_ns || session.last_event_at || '';
|
||||||
|
nextEventMap[id] = newKey;
|
||||||
|
|
||||||
// Only refresh if:
|
const oldKey = prevEventMap[id] || '';
|
||||||
// 1. Session is active/attention AND
|
if (newKey !== oldKey) {
|
||||||
// 2. last_event_at has actually changed OR it's the currently open modal
|
refreshConversationSilent(session.session_id, session.project_dir, session.agent || 'claude');
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastEventAtRef.current = nextEventMap;
|
lastEventAtRef.current = nextEventMap;
|
||||||
@@ -94,15 +110,16 @@ export function App() {
|
|||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
applyStateData(data);
|
applyStateData(data);
|
||||||
|
clearErrorCount('state-fetch');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
|
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);
|
setError(msg);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [applyStateData]);
|
}, [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) => {
|
const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', force = false) => {
|
||||||
// Skip if already fetched and not forcing refresh
|
// Skip if already fetched and not forcing refresh
|
||||||
if (!force && conversationsRef.current[sessionId]) return;
|
if (!force && conversationsRef.current[sessionId]) return;
|
||||||
@@ -115,7 +132,7 @@ export function App() {
|
|||||||
if (params.toString()) url += '?' + params.toString();
|
if (params.toString()) url += '?' + params.toString();
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn('Failed to fetch conversation for', sessionId);
|
trackError(`conversation-${sessionId}`, `Failed to fetch conversation (HTTP ${response.status})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -123,18 +140,33 @@ export function App() {
|
|||||||
...prev,
|
...prev,
|
||||||
[sessionId]: data.messages || []
|
[sessionId]: data.messages || []
|
||||||
}));
|
}));
|
||||||
|
clearErrorCount(`conversation-${sessionId}`);
|
||||||
} catch (err) {
|
} 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 respondToSession = useCallback(async (sessionId, text, isFreeform = false, optionCount = 0) => {
|
||||||
const payload = { text };
|
const payload = { text };
|
||||||
if (isFreeform) {
|
if (isFreeform) {
|
||||||
payload.freeform = true;
|
payload.freeform = true;
|
||||||
payload.optionCount = optionCount;
|
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 {
|
try {
|
||||||
const res = await fetch(API_RESPOND + encodeURIComponent(sessionId), {
|
const res = await fetch(API_RESPOND + encodeURIComponent(sessionId), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -142,20 +174,22 @@ export function App() {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok) {
|
if (!data.ok) {
|
||||||
// Trigger state refresh
|
throw new Error(data.error || 'Failed to send response');
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
clearErrorCount(`respond-${sessionId}`);
|
||||||
|
// SSE will push state update when Claude processes the message,
|
||||||
|
// which triggers conversation refresh via applyStateData
|
||||||
} catch (err) {
|
} 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
|
throw err; // Re-throw so SimpleInput/QuestionBlock can catch and show error
|
||||||
}
|
}
|
||||||
}, [fetchState, refreshConversationSilent]);
|
}, []);
|
||||||
|
|
||||||
// Dismiss a session
|
// Dismiss a session
|
||||||
const dismissSession = useCallback(async (sessionId) => {
|
const dismissSession = useCallback(async (sessionId) => {
|
||||||
@@ -167,9 +201,11 @@ export function App() {
|
|||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
// Trigger refresh
|
// Trigger refresh
|
||||||
fetchState();
|
fetchState();
|
||||||
|
} else {
|
||||||
|
trackError(`dismiss-${sessionId}`, `Failed to dismiss session: ${data.error || 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error dismissing session:', err);
|
trackError(`dismiss-${sessionId}`, `Error dismissing session: ${err.message}`);
|
||||||
}
|
}
|
||||||
}, [fetchState]);
|
}, [fetchState]);
|
||||||
|
|
||||||
@@ -185,7 +221,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
eventSource = new EventSource(API_STREAM);
|
eventSource = new EventSource(API_STREAM);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to initialize EventSource:', err);
|
trackError('sse-init', `Failed to initialize EventSource: ${err.message}`);
|
||||||
setSseConnected(false);
|
setSseConnected(false);
|
||||||
reconnectTimer = setTimeout(connect, 2000);
|
reconnectTimer = setTimeout(connect, 2000);
|
||||||
return;
|
return;
|
||||||
@@ -195,6 +231,9 @@ export function App() {
|
|||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
setSseConnected(true);
|
setSseConnected(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
// Clear event cache on reconnect to force refresh of all conversations
|
||||||
|
// (handles updates missed during disconnect)
|
||||||
|
lastEventAtRef.current = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener('state', (event) => {
|
eventSource.addEventListener('state', (event) => {
|
||||||
@@ -202,8 +241,9 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
applyStateData(data);
|
applyStateData(data);
|
||||||
|
clearErrorCount('sse-parse');
|
||||||
} catch (err) {
|
} 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
|
// Handle card click - open modal and fetch conversation if not cached
|
||||||
const handleCardClick = useCallback(async (session) => {
|
const handleCardClick = useCallback(async (session) => {
|
||||||
|
modalSessionRef.current = session.session_id;
|
||||||
setModalSession(session);
|
setModalSession(session);
|
||||||
|
|
||||||
// Fetch conversation if not already cached
|
// Fetch conversation if not already cached
|
||||||
@@ -267,6 +308,7 @@ export function App() {
|
|||||||
}, [fetchConversation]);
|
}, [fetchConversation]);
|
||||||
|
|
||||||
const handleCloseModal = useCallback(() => {
|
const handleCloseModal = useCallback(() => {
|
||||||
|
modalSessionRef.current = null;
|
||||||
setModalSession(null);
|
setModalSession(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -380,5 +422,7 @@ export function App() {
|
|||||||
onRespond=${respondToSession}
|
onRespond=${respondToSession}
|
||||||
onDismiss=${dismissSession}
|
onDismiss=${dismissSession}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<${ToastContainer} />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { html } from '../lib/preact.js';
|
|||||||
import { getUserMessageBg } from '../utils/status.js';
|
import { getUserMessageBg } from '../utils/status.js';
|
||||||
import { MessageBubble, filterDisplayMessages } from './MessageBubble.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 }) {
|
export function ChatMessages({ messages, status, limit = 20 }) {
|
||||||
const userBgClass = getUserMessageBg(status);
|
const userBgClass = getUserMessageBg(status);
|
||||||
|
|
||||||
@@ -21,7 +28,7 @@ export function ChatMessages({ messages, status, limit = 20 }) {
|
|||||||
<div class="space-y-2.5">
|
<div class="space-y-2.5">
|
||||||
${displayMessages.map((msg, i) => html`
|
${displayMessages.map((msg, i) => html`
|
||||||
<${MessageBubble}
|
<${MessageBubble}
|
||||||
key=${`${msg.role}-${msg.timestamp || (offset + i)}`}
|
key=${getMessageKey(msg, offset + i)}
|
||||||
msg=${msg}
|
msg=${msg}
|
||||||
userBg=${userBgClass}
|
userBg=${userBgClass}
|
||||||
compact=${true}
|
compact=${true}
|
||||||
|
|||||||
@@ -20,33 +20,60 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
|
|||||||
}, [session.session_id, session.project_dir, agent, conversation, onFetchConversation]);
|
}, [session.session_id, session.project_dir, agent, conversation, onFetchConversation]);
|
||||||
|
|
||||||
const chatPaneRef = useRef(null);
|
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);
|
const prevConversationLenRef = useRef(0);
|
||||||
|
|
||||||
// Track scroll position for smart scrolling
|
// Track user intent via wheel events (only fires from actual user scrolling)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = chatPaneRef.current;
|
const el = chatPaneRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleWheel = (e) => {
|
||||||
const threshold = 50;
|
// User scrolling up - accumulate distance before disabling sticky
|
||||||
wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
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);
|
el.addEventListener('wheel', handleWheel, { passive: true });
|
||||||
return () => el.removeEventListener('scroll', handleScroll);
|
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(() => {
|
useEffect(() => {
|
||||||
const el = chatPaneRef.current;
|
const el = chatPaneRef.current;
|
||||||
if (!el || !conversation) return;
|
if (!el || !conversation) return;
|
||||||
|
|
||||||
const hasNewMessages = conversation.length > prevConversationLenRef.current;
|
const prevLen = prevConversationLenRef.current;
|
||||||
prevConversationLenRef.current = conversation.length;
|
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)
|
||||||
el.scrollTop = el.scrollHeight;
|
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]);
|
}, [conversation]);
|
||||||
|
|
||||||
@@ -118,7 +145,7 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Footer (Input or Questions) -->
|
<!-- 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`
|
${hasQuestions ? html`
|
||||||
<${QuestionBlock}
|
<${QuestionBlock}
|
||||||
questions=${session.pending_questions}
|
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';
|
import { getStatusMeta } from '../utils/status.js';
|
||||||
|
|
||||||
export function SimpleInput({ sessionId, status, onRespond }) {
|
export function SimpleInput({ sessionId, status, onRespond }) {
|
||||||
@@ -6,6 +6,7 @@ export function SimpleInput({ sessionId, status, onRespond }) {
|
|||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const textareaRef = useRef(null);
|
||||||
const meta = getStatusMeta(status);
|
const meta = getStatusMeta(status);
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
@@ -22,6 +23,11 @@ export function SimpleInput({ sessionId, status, onRespond }) {
|
|||||||
console.error('SimpleInput send error:', err);
|
console.error('SimpleInput send error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
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">
|
<div class="flex items-end gap-2.5">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref=${textareaRef}
|
||||||
value=${text}
|
value=${text}
|
||||||
onInput=${(e) => {
|
onInput=${(e) => {
|
||||||
setText(e.target.value);
|
setText(e.target.value);
|
||||||
|
|||||||
@@ -74,27 +74,6 @@ export function groupSessionsByProject(sessions) {
|
|||||||
groups.get(key).sessions.push(session);
|
groups.get(key).sessions.push(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = Array.from(groups.values());
|
// Return groups in API order (no status-based reordering)
|
||||||
|
return 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user