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:
southseact-3d
2026-02-08 14:00:29 +00:00
parent 638f9ae5d2
commit 9ef54cf6ee
3 changed files with 371 additions and 2 deletions

View File

@@ -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();