diff --git a/dashboard/components/App.js b/dashboard/components/App.js
index ac271d1..523a2e4 100644
--- a/dashboard/components/App.js
+++ b/dashboard/components/App.js
@@ -10,7 +10,6 @@ export function App() {
const [sessions, setSessions] = useState([]);
const [modalSession, setModalSession] = useState(null);
const [conversations, setConversations] = useState({});
- const [conversationLoading, setConversationLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedProject, setSelectedProject] = useState(null);
@@ -40,6 +39,12 @@ export function App() {
// 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);
+ sessionsRef.current = sessions;
+ conversationsRef.current = conversations;
+
// Apply state payload from polling or SSE stream
const applyStateData = useCallback((data) => {
const newSessions = data.sessions || [];
@@ -98,13 +103,9 @@ export function App() {
}, [applyStateData]);
// Fetch conversation for a session
- const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', showLoading = false, force = false) => {
+ const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', force = false) => {
// Skip if already fetched and not forcing refresh
- if (!force && conversations[sessionId]) return;
-
- if (showLoading) {
- setConversationLoading(true);
- }
+ if (!force && conversationsRef.current[sessionId]) return;
try {
let url = API_CONVERSATION + encodeURIComponent(sessionId);
@@ -124,12 +125,8 @@ export function App() {
}));
} catch (err) {
console.error('Error fetching conversation:', err);
- } finally {
- if (showLoading) {
- setConversationLoading(false);
- }
}
- }, [conversations]);
+ }, []);
// Respond to a session's pending question
const respondToSession = useCallback(async (sessionId, text, isFreeform = false, optionCount = 0) => {
@@ -146,13 +143,19 @@ export function App() {
});
const data = await res.json();
if (data.ok) {
- // Trigger refresh
+ // 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');
+ }
}
} catch (err) {
console.error('Error responding to session:', err);
+ throw err; // Re-throw so SimpleInput/QuestionBlock can catch and show error
}
- }, [fetchState]);
+ }, [fetchState, refreshConversationSilent]);
// Dismiss a session
const dismissSession = useCallback(async (sessionId) => {
@@ -258,20 +261,9 @@ export function App() {
setModalSession(session);
// Fetch conversation if not already cached
- if (!conversations[session.session_id]) {
- await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude', true);
+ if (!conversationsRef.current[session.session_id]) {
+ await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude');
}
- }, [conversations, fetchConversation]);
-
- // Refresh conversation (force re-fetch, used after sending messages)
- const refreshConversation = useCallback(async (sessionId, projectDir, agent = 'claude') => {
- // Force refresh by clearing cache first
- setConversations(prev => {
- const updated = { ...prev };
- delete updated[sessionId];
- return updated;
- });
- await fetchConversation(sessionId, projectDir, agent, false, true);
}, [fetchConversation]);
const handleCloseModal = useCallback(() => {
@@ -383,10 +375,10 @@ export function App() {
<${Modal}
session=${modalSession}
conversations=${conversations}
- conversationLoading=${conversationLoading}
onClose=${handleCloseModal}
- onSendMessage=${respondToSession}
- onRefreshConversation=${refreshConversation}
+ onFetchConversation=${fetchConversation}
+ onRespond=${respondToSession}
+ onDismiss=${dismissSession}
/>
`;
}
diff --git a/dashboard/components/ChatMessages.js b/dashboard/components/ChatMessages.js
index 16f03b2..6023dab 100644
--- a/dashboard/components/ChatMessages.js
+++ b/dashboard/components/ChatMessages.js
@@ -2,19 +2,19 @@ import { html } from '../lib/preact.js';
import { getUserMessageBg } from '../utils/status.js';
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
-export function ChatMessages({ messages, status }) {
+export function ChatMessages({ messages, status, limit = 20 }) {
const userBgClass = getUserMessageBg(status);
if (!messages || messages.length === 0) {
return html`
- No messages yet
+ No messages to show
`;
}
const allDisplayMessages = filterDisplayMessages(messages);
- const displayMessages = allDisplayMessages.slice(-20);
+ const displayMessages = limit ? allDisplayMessages.slice(-limit) : allDisplayMessages;
const offset = allDisplayMessages.length - displayMessages.length;
return html`
diff --git a/dashboard/components/Modal.js b/dashboard/components/Modal.js
index 4a1bccd..0a1bc8f 100644
--- a/dashboard/components/Modal.js
+++ b/dashboard/components/Modal.js
@@ -1,24 +1,12 @@
-import { html, useState, useEffect, useRef, useCallback } from '../lib/preact.js';
-import { getStatusMeta, getUserMessageBg } from '../utils/status.js';
-import { formatDuration, formatTime } from '../utils/formatting.js';
-import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
+import { html, useState, useEffect, useCallback } from '../lib/preact.js';
+import { SessionCard } from './SessionCard.js';
-export function Modal({ session, conversations, conversationLoading, onClose, onSendMessage, onRefreshConversation }) {
- const [inputValue, setInputValue] = useState('');
- const [sending, setSending] = useState(false);
- const [inputFocused, setInputFocused] = useState(false);
+export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) {
const [closing, setClosing] = useState(false);
- const inputRef = useRef(null);
- const wasAtBottomRef = useRef(true);
- const prevConversationLenRef = useRef(0);
- const chatContainerRef = useRef(null);
- const conversation = session ? (conversations[session.session_id] || []) : [];
-
- // Reset state when session changes
+ // Reset closing state when session changes
useEffect(() => {
setClosing(false);
- prevConversationLenRef.current = 0;
}, [session?.session_id]);
// Animated close handler
@@ -30,40 +18,6 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
}, 200);
}, [onClose]);
- // Track scroll position
- useEffect(() => {
- const container = chatContainerRef.current;
- if (!container) return;
-
- const handleScroll = () => {
- const threshold = 50;
- wasAtBottomRef.current = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
- };
-
- container.addEventListener('scroll', handleScroll);
- return () => container.removeEventListener('scroll', handleScroll);
- }, []);
-
- // Only scroll to bottom on NEW messages, and only if user was already at bottom
- useEffect(() => {
- const container = chatContainerRef.current;
- if (!container || !conversation) return;
-
- const hasNewMessages = conversation.length > prevConversationLenRef.current;
- prevConversationLenRef.current = conversation.length;
-
- if (hasNewMessages && wasAtBottomRef.current) {
- container.scrollTop = container.scrollHeight;
- }
- }, [conversation]);
-
- // Focus input when modal opens
- useEffect(() => {
- if (inputRef.current) {
- inputRef.current.focus();
- }
- }, [session?.session_id]);
-
// Lock body scroll when modal is open
useEffect(() => {
if (!session) return;
@@ -71,9 +25,9 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
return () => {
document.body.style.overflow = '';
};
- }, [!!session]);
+ }, [session?.session_id]);
- // Handle keyboard events
+ // Handle escape key
useEffect(() => {
if (!session) return;
const handleKeyDown = (e) => {
@@ -81,146 +35,26 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
- }, [!!session, handleClose]);
+ }, [session?.session_id, handleClose]);
if (!session) return null;
- const hasPendingQuestions = session.pending_questions && session.pending_questions.length > 0;
- const optionCount = hasPendingQuestions ? session.pending_questions[0]?.options?.length || 0 : 0;
- const status = getStatusMeta(session.status);
- const agent = session.agent === 'codex' ? 'codex' : 'claude';
- const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude';
-
- const handleInputKeyDown = (e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSend();
- }
- };
-
- const handleSend = async () => {
- const text = inputValue.trim();
- if (!text || sending) return;
-
- setSending(true);
- try {
- if (onSendMessage) {
- await onSendMessage(session.session_id, text, true, optionCount);
- }
- setInputValue('');
- if (onRefreshConversation) {
- await onRefreshConversation(session.session_id, session.project_dir, agent);
- }
- } catch (err) {
- console.error('Failed to send message:', err);
- } finally {
- setSending(false);
- }
- };
-
- const displayMessages = filterDisplayMessages(conversation);
+ const conversation = conversations[session.session_id] || [];
return html`
e.target === e.currentTarget && handleClose()}
>
-
e.stopPropagation()}
- >
-
-
-
-
-
- ${conversationLoading ? html`
-
-
Loading conversation...
-
- ` : displayMessages.length > 0 ? html`
-
- ${displayMessages.map((msg, i) => html`
- <${MessageBubble}
- key=${`${msg.role}-${msg.timestamp || i}`}
- msg=${msg}
- userBg=${getUserMessageBg(session.status)}
- compact=${false}
- formatTime=${formatTime}
- />
- `)}
-
- ` : html`
-
No conversation messages
- `}
-
-
-
-
- ${hasPendingQuestions && html`
-
- Agent is waiting for a response
-
- `}
-
-
-
- Press Enter to send, Shift+Enter for new line, Escape to close
-
-
+
e.stopPropagation()}>
+ <${SessionCard}
+ session=${session}
+ conversation=${conversation}
+ onFetchConversation=${onFetchConversation}
+ onRespond=${onRespond}
+ onDismiss=${onDismiss}
+ enlarged=${true}
+ />
`;
diff --git a/dashboard/components/QuestionBlock.js b/dashboard/components/QuestionBlock.js
index 5561bd2..bf2c977 100644
--- a/dashboard/components/QuestionBlock.js
+++ b/dashboard/components/QuestionBlock.js
@@ -5,6 +5,8 @@ import { OptionButton } from './OptionButton.js';
export function QuestionBlock({ questions, sessionId, status, onRespond }) {
const [freeformText, setFreeformText] = useState('');
const [focused, setFocused] = useState(false);
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState(null);
const meta = getStatusMeta(status);
if (!questions || questions.length === 0) return null;
@@ -14,21 +16,45 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
const remainingCount = questions.length - 1;
const options = question.options || [];
- const handleOptionClick = (optionLabel) => {
- onRespond(sessionId, optionLabel, false, options.length);
+ const handleOptionClick = async (optionLabel) => {
+ if (sending) return;
+ setSending(true);
+ setError(null);
+ try {
+ await onRespond(sessionId, optionLabel, false, options.length);
+ } catch (err) {
+ setError('Failed to send response');
+ console.error('QuestionBlock option error:', err);
+ } finally {
+ setSending(false);
+ }
};
- const handleFreeformSubmit = (e) => {
+ const handleFreeformSubmit = async (e) => {
e.preventDefault();
e.stopPropagation();
- if (freeformText.trim()) {
- onRespond(sessionId, freeformText.trim(), true, options.length);
- setFreeformText('');
+ if (freeformText.trim() && !sending) {
+ setSending(true);
+ setError(null);
+ try {
+ await onRespond(sessionId, freeformText.trim(), true, options.length);
+ setFreeformText('');
+ } catch (err) {
+ setError('Failed to send response');
+ console.error('QuestionBlock freeform error:', err);
+ } finally {
+ setSending(false);
+ }
}
};
return html`
e.stopPropagation()}>
+ ${error && html`
+
+ ${error}
+
+ `}
${question.header && html`
@@ -75,13 +101,15 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
rows="1"
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
+ disabled=${sending}
/>
diff --git a/dashboard/components/SessionCard.js b/dashboard/components/SessionCard.js
index 676110f..280ffcd 100644
--- a/dashboard/components/SessionCard.js
+++ b/dashboard/components/SessionCard.js
@@ -5,7 +5,7 @@ import { ChatMessages } from './ChatMessages.js';
import { QuestionBlock } from './QuestionBlock.js';
import { SimpleInput } from './SimpleInput.js';
-export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss }) {
+export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss, enlarged = false }) {
const hasQuestions = session.pending_questions && session.pending_questions.length > 0;
const statusMeta = getStatusMeta(session.status);
const agent = session.agent === 'codex' ? 'codex' : 'claude';
@@ -20,23 +20,51 @@ 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 prevConversationLenRef = useRef(0);
- // Scroll chat pane to bottom when conversation loads or updates
+ // Track scroll position for smart scrolling
useEffect(() => {
const el = chatPaneRef.current;
- if (el) el.scrollTop = el.scrollHeight;
+ if (!el) return;
+
+ const handleScroll = () => {
+ const threshold = 50;
+ wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
+ };
+
+ el.addEventListener('scroll', handleScroll);
+ return () => el.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ // Smart scroll: only scroll to bottom on new messages if user was already at bottom
+ useEffect(() => {
+ const el = chatPaneRef.current;
+ if (!el || !conversation) return;
+
+ const hasNewMessages = conversation.length > prevConversationLenRef.current;
+ prevConversationLenRef.current = conversation.length;
+
+ if (hasNewMessages && wasAtBottomRef.current) {
+ el.scrollTop = el.scrollHeight;
+ }
}, [conversation]);
const handleDismissClick = (e) => {
e.stopPropagation();
- onDismiss(session.session_id);
+ if (onDismiss) onDismiss(session.session_id);
};
+ // Container classes differ based on enlarged mode
+ const containerClasses = enlarged
+ ? 'glass-panel flex w-full max-w-[90vw] max-h-[90vh] flex-col overflow-hidden rounded-2xl border border-selection/80'
+ : 'glass-panel flex h-[850px] max-h-[850px] w-[600px] cursor-pointer flex-col overflow-hidden rounded-xl border border-selection/70 transition-[border-color,box-shadow] duration-200 hover:border-starting/35 hover:shadow-panel';
+
return html`
onClick(session)}
+ onClick=${enlarged ? undefined : () => onClick && onClick(session)}
>