feat: display OpenCode todos with status on builder page
- Capture todowrite tool events and store todos on messages - Add API endpoint GET /api/sessions/:sessionId/todos - Clear todos on message finish, undo, and redo - Create renderStructuredTodos function with status icons - Integrate todo display into message rendering - Add CSS styling for todo items by status and priority
This commit is contained in:
@@ -382,6 +382,8 @@ const state = {
|
||||
accountPlan: 'hobby',
|
||||
isAdmin: false,
|
||||
usageSummary: null,
|
||||
todos: [], // Current todos from OpenCode
|
||||
currentMessageId: null, // Track which message we're displaying todos for
|
||||
};
|
||||
|
||||
// Expose state for builder.html
|
||||
@@ -1849,10 +1851,12 @@ function renderMessages(session) {
|
||||
undoBtn.className = 'ghost';
|
||||
undoBtn.style.marginLeft = '8px';
|
||||
undoBtn.textContent = '↺ Undo';
|
||||
undoBtn.onclick = async () => {
|
||||
undoBtn.onclick = async () => {
|
||||
undoBtn.disabled = true;
|
||||
undoBtn.textContent = '↺ Undoing...';
|
||||
try {
|
||||
// Clear todos before undoing
|
||||
clearTodos();
|
||||
await api(`/api/sessions/${session.id}/messages/${msg.id}/undo`, {
|
||||
method: 'POST',
|
||||
});
|
||||
@@ -1871,10 +1875,12 @@ function renderMessages(session) {
|
||||
redoBtn.className = 'ghost';
|
||||
redoBtn.style.marginLeft = '8px';
|
||||
redoBtn.textContent = '↻ Redo';
|
||||
redoBtn.onclick = async () => {
|
||||
redoBtn.onclick = async () => {
|
||||
redoBtn.disabled = true;
|
||||
redoBtn.textContent = '↻ Redoing...';
|
||||
try {
|
||||
// Clear todos before redoing
|
||||
clearTodos();
|
||||
await redoMessage(msg, session);
|
||||
} catch (err) {
|
||||
setStatus('Redo failed: ' + err.message);
|
||||
@@ -1938,6 +1944,15 @@ function renderMessages(session) {
|
||||
summary.textContent = `Opencode output: ${msg.opencodeSummary}`;
|
||||
assistantCard.appendChild(summary);
|
||||
}
|
||||
|
||||
// Render todos if they exist on the message
|
||||
if (msg.todos && Array.isArray(msg.todos) && msg.todos.length > 0) {
|
||||
const todoContainer = renderStructuredTodos(msg.todos);
|
||||
if (todoContainer) {
|
||||
assistantCard.appendChild(todoContainer);
|
||||
}
|
||||
}
|
||||
|
||||
const rawPre = document.createElement('pre');
|
||||
rawPre.className = 'raw-output muted';
|
||||
rawPre.style.display = 'none';
|
||||
@@ -2059,6 +2074,183 @@ function renderContentWithTodos(text) {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// Render structured todos with status and priority
|
||||
function renderStructuredTodos(todos) {
|
||||
if (!todos || !Array.isArray(todos) || todos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'todo-container';
|
||||
container.style.marginTop = '16px';
|
||||
container.style.marginBottom = '16px';
|
||||
container.style.padding = '16px';
|
||||
container.style.background = '#f8fffc';
|
||||
container.style.border = '1px solid rgba(0, 128, 96, 0.2)';
|
||||
container.style.borderRadius = '12px';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.display = 'flex';
|
||||
header.style.alignItems = 'center';
|
||||
header.style.gap = '8px';
|
||||
header.style.marginBottom = '12px';
|
||||
header.style.fontWeight = '600';
|
||||
header.style.color = 'var(--shopify-green)';
|
||||
header.style.fontSize = '14px';
|
||||
header.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 11l3 3L22 4"></path>
|
||||
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"></path>
|
||||
</svg>
|
||||
Tasks (${todos.length})
|
||||
`;
|
||||
container.appendChild(header);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.style.display = 'flex';
|
||||
list.style.flexDirection = 'column';
|
||||
list.style.gap = '8px';
|
||||
|
||||
todos.forEach((todo) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = `todo-item todo-status-${todo.status}`;
|
||||
item.style.display = 'flex';
|
||||
item.style.alignItems = 'flex-start';
|
||||
item.style.gap = '10px';
|
||||
item.style.padding = '10px 12px';
|
||||
item.style.borderRadius = '8px';
|
||||
item.style.background = '#fff';
|
||||
item.style.border = '1px solid var(--border)';
|
||||
item.style.transition = 'all 0.2s ease';
|
||||
|
||||
// Status icon
|
||||
const statusIcon = document.createElement('span');
|
||||
statusIcon.className = 'todo-status-icon';
|
||||
statusIcon.style.flexShrink = '0';
|
||||
statusIcon.style.width = '20px';
|
||||
statusIcon.style.height = '20px';
|
||||
statusIcon.style.display = 'flex';
|
||||
statusIcon.style.alignItems = 'center';
|
||||
statusIcon.style.justifyContent = 'center';
|
||||
statusIcon.style.fontSize = '14px';
|
||||
|
||||
// Set icon and color based on status
|
||||
switch (todo.status) {
|
||||
case 'completed':
|
||||
statusIcon.innerHTML = '✓';
|
||||
statusIcon.style.color = '#10b981';
|
||||
item.style.borderColor = 'rgba(16, 185, 129, 0.3)';
|
||||
item.style.background = 'rgba(16, 185, 129, 0.05)';
|
||||
break;
|
||||
case 'in_progress':
|
||||
statusIcon.innerHTML = '●';
|
||||
statusIcon.style.color = '#f59e0b';
|
||||
item.style.borderColor = 'rgba(245, 158, 11, 0.3)';
|
||||
item.style.background = 'rgba(245, 158, 11, 0.05)';
|
||||
break;
|
||||
case 'cancelled':
|
||||
statusIcon.innerHTML = '✗';
|
||||
statusIcon.style.color = '#6b7280';
|
||||
item.style.opacity = '0.6';
|
||||
item.style.textDecoration = 'line-through';
|
||||
break;
|
||||
case 'pending':
|
||||
default:
|
||||
statusIcon.innerHTML = '○';
|
||||
statusIcon.style.color = '#9ca3af';
|
||||
break;
|
||||
}
|
||||
|
||||
const content = document.createElement('span');
|
||||
content.className = 'todo-content';
|
||||
content.style.flex = '1';
|
||||
content.style.fontSize = '14px';
|
||||
content.style.lineHeight = '1.4';
|
||||
content.style.color = 'var(--ink)';
|
||||
content.textContent = todo.content || '';
|
||||
|
||||
// Priority badge
|
||||
if (todo.priority && todo.priority !== 'medium') {
|
||||
const priorityBadge = document.createElement('span');
|
||||
priorityBadge.className = `todo-priority-${todo.priority}`;
|
||||
priorityBadge.style.fontSize = '10px';
|
||||
priorityBadge.style.fontWeight = '700';
|
||||
priorityBadge.style.textTransform = 'uppercase';
|
||||
priorityBadge.style.letterSpacing = '0.02em';
|
||||
priorityBadge.style.padding = '2px 6px';
|
||||
priorityBadge.style.borderRadius = '4px';
|
||||
priorityBadge.style.flexShrink = '0';
|
||||
|
||||
if (todo.priority === 'high') {
|
||||
priorityBadge.textContent = 'High';
|
||||
priorityBadge.style.background = '#fee2e2';
|
||||
priorityBadge.style.color = '#dc2626';
|
||||
} else if (todo.priority === 'low') {
|
||||
priorityBadge.textContent = 'Low';
|
||||
priorityBadge.style.background = '#dbeafe';
|
||||
priorityBadge.style.color = '#2563eb';
|
||||
}
|
||||
|
||||
content.appendChild(document.createTextNode(' '));
|
||||
content.appendChild(priorityBadge);
|
||||
}
|
||||
|
||||
item.appendChild(statusIcon);
|
||||
item.appendChild(content);
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
container.appendChild(list);
|
||||
return container;
|
||||
}
|
||||
|
||||
// Clear todos from the UI
|
||||
function clearTodos() {
|
||||
state.todos = [];
|
||||
state.currentMessageId = null;
|
||||
const existingContainer = document.querySelector('.todo-container');
|
||||
if (existingContainer) {
|
||||
existingContainer.remove();
|
||||
}
|
||||
console.log('[TODOS] Cleared todos from UI');
|
||||
}
|
||||
|
||||
// Update todos in the UI
|
||||
function updateTodos(todos, messageId) {
|
||||
if (!todos || !Array.isArray(todos) || todos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update if this is for the current message or a new message
|
||||
if (state.currentMessageId && state.currentMessageId !== messageId) {
|
||||
console.log('[TODOS] Ignoring todos for different message', { current: state.currentMessageId, received: messageId });
|
||||
return;
|
||||
}
|
||||
|
||||
state.todos = todos;
|
||||
state.currentMessageId = messageId;
|
||||
|
||||
// Remove existing todo container
|
||||
const existingContainer = document.querySelector('.todo-container');
|
||||
if (existingContainer) {
|
||||
existingContainer.remove();
|
||||
}
|
||||
|
||||
// Find the latest assistant message to append todos to
|
||||
const assistantMessages = document.querySelectorAll('.message.assistant');
|
||||
if (assistantMessages.length === 0) {
|
||||
console.log('[TODOS] No assistant message found to attach todos to');
|
||||
return;
|
||||
}
|
||||
|
||||
const latestMessage = assistantMessages[assistantMessages.length - 1];
|
||||
const todoContainer = renderStructuredTodos(todos);
|
||||
if (todoContainer) {
|
||||
latestMessage.appendChild(todoContainer);
|
||||
console.log('[TODOS] Updated todos in UI', { count: todos.length, messageId });
|
||||
}
|
||||
}
|
||||
|
||||
function renderModelIcon(selectedValue) {
|
||||
if (!el.modelIcon) return;
|
||||
if (!selectedValue) {
|
||||
@@ -2644,6 +2836,8 @@ function streamMessage(sessionId, messageId) {
|
||||
setStatus('OpenCode is responding...');
|
||||
startUsagePolling(); // Start aggressive polling when OpenCode starts
|
||||
// Keep loading indicator spinning - don't hide when OpenCode starts
|
||||
// Clear todos when a new message starts
|
||||
clearTodos();
|
||||
} else if (data.type === 'chunk') {
|
||||
// Update partial output immediately
|
||||
message.partialOutput = data.filtered || data.partialOutput || data.content;
|
||||
@@ -2656,6 +2850,13 @@ function streamMessage(sessionId, messageId) {
|
||||
// Re-render messages to show new content immediately
|
||||
renderMessages(session);
|
||||
setStatus('Streaming response...');
|
||||
} else if (data.type === 'todos') {
|
||||
// Handle todo updates from OpenCode
|
||||
if (data.todos && Array.isArray(data.todos)) {
|
||||
message.todos = data.todos;
|
||||
updateTodos(data.todos, messageId);
|
||||
console.log('[TODOS] Received todo update via SSE', { count: data.todos.length, messageId });
|
||||
}
|
||||
} else if (data.type === 'health') {
|
||||
// Sync status from server heartbeat
|
||||
if (data.status && message.status !== data.status) {
|
||||
@@ -2675,6 +2876,10 @@ function streamMessage(sessionId, messageId) {
|
||||
message.opencodeExitCode = data.exitCode;
|
||||
eventSource.close();
|
||||
state.activeStreams.delete(messageId);
|
||||
|
||||
// Clear todos when message completes (to start fresh for next message)
|
||||
clearTodos();
|
||||
|
||||
renderMessages(session);
|
||||
setStatus('Complete');
|
||||
|
||||
@@ -2717,6 +2922,9 @@ function streamMessage(sessionId, messageId) {
|
||||
eventSource.close();
|
||||
state.activeStreams.delete(messageId);
|
||||
|
||||
// Clear todos on error
|
||||
clearTodos();
|
||||
|
||||
renderMessages(session);
|
||||
scrollChatToBottom();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user