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>
429 lines
16 KiB
JavaScript
429 lines
16 KiB
JavaScript
import { html, useState, useEffect, useCallback, useMemo, useRef } from '../lib/preact.js';
|
|
import { API_STATE, API_STREAM, API_DISMISS, API_RESPOND, API_CONVERSATION, POLL_MS, fetchWithTimeout } from '../utils/api.js';
|
|
import { groupSessionsByProject } from '../utils/status.js';
|
|
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([]);
|
|
const [modalSession, setModalSession] = useState(null);
|
|
const [conversations, setConversations] = useState({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [selectedProject, setSelectedProject] = useState(null);
|
|
const [sseConnected, setSseConnected] = useState(false);
|
|
|
|
// Background conversation refresh with error tracking
|
|
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
|
|
try {
|
|
let url = API_CONVERSATION + encodeURIComponent(sessionId);
|
|
const params = new URLSearchParams();
|
|
if (projectDir) params.set('project_dir', projectDir);
|
|
if (agent) params.set('agent', agent);
|
|
if (params.toString()) url += '?' + params.toString();
|
|
const response = await fetch(url);
|
|
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) {
|
|
trackError(`conversation-${sessionId}`, `Failed to fetch conversation: ${err.message}`);
|
|
}
|
|
}, []);
|
|
|
|
// Track last_event_at for each session to detect actual changes
|
|
const lastEventAtRef = useRef({});
|
|
|
|
// 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.)
|
|
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;
|
|
});
|
|
|
|
// 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;
|
|
// 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;
|
|
|
|
const oldKey = prevEventMap[id] || '';
|
|
if (newKey !== oldKey) {
|
|
refreshConversationSilent(session.session_id, session.project_dir, session.agent || 'claude');
|
|
}
|
|
}
|
|
lastEventAtRef.current = nextEventMap;
|
|
|
|
setLoading(false);
|
|
}, [refreshConversationSilent]);
|
|
|
|
// Fetch state from API
|
|
const fetchState = useCallback(async () => {
|
|
try {
|
|
const response = await fetchWithTimeout(API_STATE);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
applyStateData(data);
|
|
clearErrorCount('state-fetch');
|
|
} catch (err) {
|
|
const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
|
|
trackError('state-fetch', `Failed to fetch state: ${msg}`);
|
|
setError(msg);
|
|
setLoading(false);
|
|
}
|
|
}, [applyStateData]);
|
|
|
|
// 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;
|
|
|
|
try {
|
|
let url = API_CONVERSATION + encodeURIComponent(sessionId);
|
|
const params = new URLSearchParams();
|
|
if (projectDir) params.set('project_dir', projectDir);
|
|
if (agent) params.set('agent', agent);
|
|
if (params.toString()) url += '?' + params.toString();
|
|
const response = await fetch(url);
|
|
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}`);
|
|
} catch (err) {
|
|
trackError(`conversation-${sessionId}`, `Error fetching conversation: ${err.message}`);
|
|
}
|
|
}, []);
|
|
|
|
// 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',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = await res.json();
|
|
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) {
|
|
// 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
|
|
}
|
|
}, []);
|
|
|
|
// Dismiss a session
|
|
const dismissSession = useCallback(async (sessionId) => {
|
|
try {
|
|
const res = await fetch(API_DISMISS + encodeURIComponent(sessionId), {
|
|
method: 'POST'
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
// Trigger refresh
|
|
fetchState();
|
|
} else {
|
|
trackError(`dismiss-${sessionId}`, `Failed to dismiss session: ${data.error || 'Unknown error'}`);
|
|
}
|
|
} catch (err) {
|
|
trackError(`dismiss-${sessionId}`, `Error dismissing session: ${err.message}`);
|
|
}
|
|
}, [fetchState]);
|
|
|
|
// Subscribe to live state updates via SSE
|
|
useEffect(() => {
|
|
let eventSource = null;
|
|
let reconnectTimer = null;
|
|
let stopped = false;
|
|
|
|
const connect = () => {
|
|
if (stopped) return;
|
|
|
|
try {
|
|
eventSource = new EventSource(API_STREAM);
|
|
} catch (err) {
|
|
trackError('sse-init', `Failed to initialize EventSource: ${err.message}`);
|
|
setSseConnected(false);
|
|
reconnectTimer = setTimeout(connect, 2000);
|
|
return;
|
|
}
|
|
|
|
eventSource.addEventListener('open', () => {
|
|
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) => {
|
|
if (stopped) return;
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
applyStateData(data);
|
|
clearErrorCount('sse-parse');
|
|
} catch (err) {
|
|
trackError('sse-parse', `Failed to parse SSE state payload: ${err.message}`);
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('error', () => {
|
|
if (stopped) return;
|
|
setSseConnected(false);
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
if (!reconnectTimer) {
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
connect();
|
|
}, 2000);
|
|
}
|
|
});
|
|
};
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
stopped = true;
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer);
|
|
}
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
}
|
|
};
|
|
}, [applyStateData]);
|
|
|
|
// Poll for updates only when SSE is disconnected (fallback mode)
|
|
useEffect(() => {
|
|
if (sseConnected) return;
|
|
|
|
fetchState();
|
|
const interval = setInterval(fetchState, POLL_MS);
|
|
return () => clearInterval(interval);
|
|
}, [fetchState, sseConnected]);
|
|
|
|
// Group sessions by project
|
|
const projectGroups = groupSessionsByProject(sessions);
|
|
|
|
// Filter sessions based on selected project
|
|
const filteredGroups = useMemo(() => {
|
|
if (selectedProject === null) {
|
|
return projectGroups;
|
|
}
|
|
return projectGroups.filter(g => g.projectDir === selectedProject);
|
|
}, [projectGroups, selectedProject]);
|
|
|
|
// 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
|
|
if (!conversationsRef.current[session.session_id]) {
|
|
await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude');
|
|
}
|
|
}, [fetchConversation]);
|
|
|
|
const handleCloseModal = useCallback(() => {
|
|
modalSessionRef.current = null;
|
|
setModalSession(null);
|
|
}, []);
|
|
|
|
const handleSelectProject = useCallback((projectDir) => {
|
|
setSelectedProject(projectDir);
|
|
}, []);
|
|
|
|
return html`
|
|
<!-- Sidebar -->
|
|
<${Sidebar}
|
|
projectGroups=${projectGroups}
|
|
selectedProject=${selectedProject}
|
|
onSelectProject=${handleSelectProject}
|
|
totalSessions=${sessions.length}
|
|
/>
|
|
|
|
<!-- Main Content (offset for sidebar) -->
|
|
<div class="ml-80 min-h-screen pb-10">
|
|
<!-- Compact Header -->
|
|
<header class="sticky top-0 z-30 border-b border-selection/50 bg-surface/95 px-6 py-4 backdrop-blur-sm">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="font-display text-lg font-semibold text-bright">
|
|
${selectedProject === null ? 'All Projects' : filteredGroups[0]?.projectName || 'Project'}
|
|
</h2>
|
|
<p class="mt-0.5 font-mono text-micro text-dim">
|
|
${filteredGroups.reduce((sum, g) => sum + g.sessions.length, 0)} session${filteredGroups.reduce((sum, g) => sum + g.sessions.length, 0) === 1 ? '' : 's'}
|
|
${selectedProject !== null && filteredGroups[0]?.projectDir ? html` in <span class="text-dim/80">${filteredGroups[0].projectDir}</span>` : ''}
|
|
</p>
|
|
</div>
|
|
<!-- Status summary chips -->
|
|
<div class="flex items-center gap-2">
|
|
${(() => {
|
|
const counts = { needs_attention: 0, active: 0, starting: 0, done: 0 };
|
|
for (const g of filteredGroups) {
|
|
for (const s of g.sessions) {
|
|
counts[s.status] = (counts[s.status] || 0) + 1;
|
|
}
|
|
}
|
|
return html`
|
|
${counts.needs_attention > 0 && html`
|
|
<div class="rounded-lg border border-attention/40 bg-attention/12 px-2.5 py-1 text-attention">
|
|
<span class="font-mono text-sm font-medium tabular-nums">${counts.needs_attention}</span>
|
|
<span class="ml-1 text-micro uppercase tracking-wider">attention</span>
|
|
</div>
|
|
`}
|
|
${counts.active > 0 && html`
|
|
<div class="rounded-lg border border-active/40 bg-active/12 px-2.5 py-1 text-active">
|
|
<span class="font-mono text-sm font-medium tabular-nums">${counts.active}</span>
|
|
<span class="ml-1 text-micro uppercase tracking-wider">active</span>
|
|
</div>
|
|
`}
|
|
${counts.starting > 0 && html`
|
|
<div class="rounded-lg border border-starting/40 bg-starting/12 px-2.5 py-1 text-starting">
|
|
<span class="font-mono text-sm font-medium tabular-nums">${counts.starting}</span>
|
|
<span class="ml-1 text-micro uppercase tracking-wider">starting</span>
|
|
</div>
|
|
`}
|
|
${counts.done > 0 && html`
|
|
<div class="rounded-lg border border-done/40 bg-done/12 px-2.5 py-1 text-done">
|
|
<span class="font-mono text-sm font-medium tabular-nums">${counts.done}</span>
|
|
<span class="ml-1 text-micro uppercase tracking-wider">done</span>
|
|
</div>
|
|
`}
|
|
`;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="px-6 pb-6 pt-6">
|
|
${loading ? html`
|
|
<div class="glass-panel animate-fade-in-up flex items-center justify-center rounded-2xl py-24">
|
|
<div class="font-mono text-dim">Loading sessions...</div>
|
|
</div>
|
|
` : error ? html`
|
|
<div class="glass-panel animate-fade-in-up flex items-center justify-center rounded-2xl py-24">
|
|
<div class="text-center">
|
|
<p class="mb-2 font-display text-lg text-attention">Failed to connect to API</p>
|
|
<p class="font-mono text-sm text-dim">${error}</p>
|
|
</div>
|
|
</div>
|
|
` : filteredGroups.length === 0 ? html`
|
|
<${EmptyState} />
|
|
` : html`
|
|
<!-- Sessions Grid (no project grouping header since sidebar shows selection) -->
|
|
<div class="flex flex-wrap gap-4">
|
|
${filteredGroups.flatMap(group =>
|
|
group.sessions.map(session => html`
|
|
<${SessionCard}
|
|
key=${session.session_id}
|
|
session=${session}
|
|
onClick=${handleCardClick}
|
|
conversation=${conversations[session.session_id]}
|
|
onFetchConversation=${fetchConversation}
|
|
onRespond=${respondToSession}
|
|
onDismiss=${dismissSession}
|
|
/>
|
|
`)
|
|
)}
|
|
</div>
|
|
`}
|
|
</main>
|
|
</div>
|
|
|
|
<${Modal}
|
|
session=${modalSession}
|
|
conversations=${conversations}
|
|
onClose=${handleCloseModal}
|
|
onFetchConversation=${fetchConversation}
|
|
onRespond=${respondToSession}
|
|
onDismiss=${dismissSession}
|
|
/>
|
|
|
|
<${ToastContainer} />
|
|
`;
|
|
}
|