unify card/modal

This commit is contained in:
teernisse
2026-02-26 10:20:26 -05:00
parent 31862f3a40
commit fa1ad4b22b
7 changed files with 522 additions and 238 deletions

View File

@@ -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}
/>
`;
}

View File

@@ -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`
<div class="flex h-full items-center justify-center rounded-xl border border-dashed border-selection/70 bg-bg/30 px-4 text-center text-sm text-dim">
No messages yet
No messages to show
</div>
`;
}
const allDisplayMessages = filterDisplayMessages(messages);
const displayMessages = allDisplayMessages.slice(-20);
const displayMessages = limit ? allDisplayMessages.slice(-limit) : allDisplayMessages;
const offset = allDisplayMessages.length - displayMessages.length;
return html`

View File

@@ -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`
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm ${closing ? 'modal-backdrop-out' : 'modal-backdrop-in'}"
onClick=${(e) => e.target === e.currentTarget && handleClose()}
>
<div
class="glass-panel flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-selection/80 ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
style=${{ borderLeftWidth: '3px', borderLeftColor: status.borderColor }}
onClick=${(e) => e.stopPropagation()}
>
<!-- Modal Header -->
<div class="flex items-center justify-between border-b px-5 py-4 ${agentHeaderClass}">
<div class="flex-1 min-w-0">
<div class="mb-1 flex items-center gap-3">
<h2 class="truncate font-display text-xl font-semibold text-bright">${session.project || session.name || session.session_id}</h2>
<div class="flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${status.badge}">
<span class="h-2 w-2 rounded-full ${status.dot} ${status.spinning ? 'spinner-dot' : ''}" style=${{ color: status.borderColor }}></span>
<span class="font-mono uppercase tracking-[0.14em]">${status.label}</span>
</div>
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${agent === 'codex' ? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300' : 'border-violet-400/45 bg-violet-500/14 text-violet-300'}">
${agent}
</span>
</div>
<div class="flex items-center gap-4 text-sm text-dim">
<span class="truncate font-mono">${session.cwd || 'No working directory'}</span>
${session.started_at && html`
<span class="flex-shrink-0 font-mono">Running ${formatDuration(session.started_at)}</span>
`}
</div>
</div>
<button
class="ml-4 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-selection/70 text-dim transition-colors duration-150 hover:border-done/35 hover:bg-done/10 hover:text-bright"
onClick=${handleClose}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Modal Content -->
<div ref=${chatContainerRef} class="flex-1 overflow-y-auto overflow-x-hidden bg-surface p-5">
${conversationLoading ? html`
<div class="flex items-center justify-center py-12 animate-fade-in-up">
<div class="font-mono text-dim">Loading conversation...</div>
</div>
` : displayMessages.length > 0 ? html`
<div class="space-y-4">
${displayMessages.map((msg, i) => html`
<${MessageBubble}
key=${`${msg.role}-${msg.timestamp || i}`}
msg=${msg}
userBg=${getUserMessageBg(session.status)}
compact=${false}
formatTime=${formatTime}
/>
`)}
</div>
` : html`
<p class="text-dim text-center py-12">No conversation messages</p>
`}
</div>
<!-- Modal Footer -->
<div class="border-t border-selection/70 bg-bg/55 p-4">
${hasPendingQuestions && html`
<div class="mb-3 rounded-xl border border-attention/40 bg-attention/12 px-3 py-2 text-sm text-attention">
Agent is waiting for a response
</div>
`}
<div class="flex items-end gap-2.5">
<textarea
ref=${inputRef}
value=${inputValue}
onInput=${(e) => {
setInputValue(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = Math.min(e.target.scrollHeight, 150) + 'px';
}}
onKeyDown=${handleInputKeyDown}
onFocus=${() => setInputFocused(true)}
onBlur=${() => setInputFocused(false)}
placeholder=${hasPendingQuestions ? "Type your response..." : "Send a message..."}
rows="1"
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-4 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
style=${{ minHeight: '42px', maxHeight: '150px', borderColor: inputFocused ? status.borderColor : undefined }}
disabled=${sending}
/>
<button
class="rounded-xl px-4 py-2 font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
style=${{ backgroundColor: status.borderColor, color: '#0a0f18' }}
onClick=${handleSend}
disabled=${sending || !inputValue.trim()}
>
${sending ? 'Sending...' : 'Send'}
</button>
</div>
<div class="mt-2 font-mono text-label text-dim">
Press Enter to send, Shift+Enter for new line, Escape to close
</div>
</div>
<div class=${closing ? 'modal-panel-out' : 'modal-panel-in'} onClick=${(e) => e.stopPropagation()}>
<${SessionCard}
session=${session}
conversation=${conversation}
onFetchConversation=${onFetchConversation}
onRespond=${onRespond}
onDismiss=${onDismiss}
enlarged=${true}
/>
</div>
</div>
`;

View File

@@ -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`
<div class="space-y-3" onClick=${(e) => e.stopPropagation()}>
${error && html`
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
${error}
</div>
`}
<!-- Question Header Badge -->
${question.header && html`
<span class="inline-flex rounded-full border px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] ${meta.badge}">
@@ -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}
/>
<button
type="submit"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
disabled=${sending || !freeformText.trim()}
>
Send
${sending ? 'Sending...' : 'Send'}
</button>
</form>

View File

@@ -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`
<div
class="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"
class=${containerClasses}
style=${{ borderLeftWidth: '3px', borderLeftColor: statusMeta.borderColor }}
onClick=${() => onClick(session)}
onClick=${enlarged ? undefined : () => onClick && onClick(session)}
>
<!-- Card Header -->
<div class="shrink-0 border-b px-4 py-3 ${agentHeaderClass}">
@@ -86,7 +114,7 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
<!-- Card Content Area (Chat) -->
<div ref=${chatPaneRef} class="flex-1 min-h-0 overflow-y-auto bg-surface px-4 py-4">
<${ChatMessages} messages=${conversation || []} status=${session.status} />
<${ChatMessages} messages=${conversation || []} status=${session.status} limit=${enlarged ? null : 20} />
</div>
<!-- Card Footer (Input or Questions) -->

View File

@@ -4,19 +4,36 @@ import { getStatusMeta } from '../utils/status.js';
export function SimpleInput({ sessionId, status, onRespond }) {
const [text, setText] = useState('');
const [focused, setFocused] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
const meta = getStatusMeta(status);
const handleSubmit = (e) => {
const handleSubmit = async (e) => {
e.preventDefault();
e.stopPropagation();
if (text.trim()) {
onRespond(sessionId, text.trim(), true, 0);
setText('');
if (text.trim() && !sending) {
setSending(true);
setError(null);
try {
await onRespond(sessionId, text.trim(), true, 0);
setText('');
} catch (err) {
setError('Failed to send message');
console.error('SimpleInput send error:', err);
} finally {
setSending(false);
}
}
};
return html`
<form onSubmit=${handleSubmit} class="flex items-end gap-2.5" onClick=${(e) => e.stopPropagation()}>
<form onSubmit=${handleSubmit} class="flex flex-col gap-2" onClick=${(e) => e.stopPropagation()}>
${error && html`
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
${error}
</div>
`}
<div class="flex items-end gap-2.5">
<textarea
value=${text}
onInput=${(e) => {
@@ -36,14 +53,17 @@ export function SimpleInput({ 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}
/>
<button
type="submit"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
disabled=${sending || !text.trim()}
>
Send
${sending ? 'Sending...' : 'Send'}
</button>
</div>
</form>
`;
}