diff --git a/chat/public/builder.html b/chat/public/builder.html
index 44e026b..53c2bcd 100644
--- a/chat/public/builder.html
+++ b/chat/public/builder.html
@@ -926,6 +926,91 @@
.model-option-text { font-weight:700; color:var(--ink); }
.model-option-multiplier { margin-left:auto; background:#f5f7f9; color:var(--muted); padding:4px 8px; border-radius:999px; font-weight:700; font-size:12px; }
#model-select-multiplier { margin-left:8px; padding:2px 8px; background:#f5f7f9; border-radius:999px; font-weight:700; font-size:12px; color:var(--muted); display:none; }
+
+ /* Todo container styles */
+ .todo-container {
+ margin: 16px 0;
+ padding: 16px;
+ background: #f8fffc;
+ border: 1px solid rgba(0, 128, 96, 0.2);
+ border-radius: 12px;
+ }
+
+ .todo-container .todo-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 10px 12px;
+ margin-bottom: 8px;
+ border-radius: 8px;
+ background: #fff;
+ border: 1px solid var(--border);
+ transition: all 0.2s ease;
+ }
+
+ .todo-container .todo-item:last-child {
+ margin-bottom: 0;
+ }
+
+ .todo-container .todo-status-icon {
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ }
+
+ .todo-container .todo-content {
+ flex: 1;
+ font-size: 14px;
+ line-height: 1.4;
+ color: var(--ink);
+ }
+
+ /* Status-specific styles */
+ .todo-status-completed {
+ border-color: rgba(16, 185, 129, 0.3) !important;
+ background: rgba(16, 185, 129, 0.05) !important;
+ }
+
+ .todo-status-completed .todo-status-icon {
+ color: #10b981;
+ }
+
+ .todo-status-in_progress {
+ border-color: rgba(245, 158, 11, 0.3) !important;
+ background: rgba(245, 158, 11, 0.05) !important;
+ }
+
+ .todo-status-in_progress .todo-status-icon {
+ color: #f59e0b;
+ }
+
+ .todo-status-cancelled {
+ opacity: 0.6;
+ text-decoration: line-through;
+ }
+
+ .todo-status-cancelled .todo-status-icon {
+ color: #6b7280;
+ }
+
+ .todo-status-pending .todo-status-icon {
+ color: #9ca3af;
+ }
+
+ /* Priority badges */
+ .todo-priority-high {
+ background: #fee2e2;
+ color: #dc2626;
+ }
+
+ .todo-priority-low {
+ background: #dbeafe;
+ color: #2563eb;
+ }
}
diff --git a/chat/public/builder.js b/chat/public/builder.js
index e3f7b22..cbd3c0f 100644
--- a/chat/public/builder.js
+++ b/chat/public/builder.js
@@ -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 = `
+
+ 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();
diff --git a/chat/server.js b/chat/server.js
index aeaf580..faab457 100644
--- a/chat/server.js
+++ b/chat/server.js
@@ -8807,6 +8807,36 @@ async function sendToOpencode({ session, model, content, message, cli, streamCal
}
}
+ // Capture todo tool events
+ if (event.type === 'tool' && event.tool === 'todowrite' && event.input?.todos) {
+ const todos = event.input.todos;
+ if (message) {
+ message.todos = todos;
+ log('Captured todos from todowrite tool', {
+ messageId: messageKey,
+ todoCount: todos.length,
+ todos: todos.map(t => ({ id: t.id, content: t.content?.substring(0, 50), status: t.status }))
+ });
+
+ // Broadcast todos to SSE clients
+ if (messageKey && activeStreams.has(messageKey)) {
+ const streams = activeStreams.get(messageKey);
+ const data = JSON.stringify({
+ type: 'todos',
+ todos: todos,
+ timestamp: new Date().toISOString()
+ });
+ streams.forEach(res => {
+ try {
+ res.write(`data: ${data}\n\n`);
+ } catch (err) {
+ log('SSE todos write error', { err: String(err) });
+ }
+ });
+ }
+ }
+ }
+
// Extract text from text events
if (event.type === 'text' && event.part?.text) {
partialOutput += event.part.text;
@@ -14622,6 +14652,29 @@ async function handleListSessions(req, res, userId) {
async function handleGetSession(_req, res, sessionId, userId) { const session = getSession(sessionId, userId); if (!session) return sendJson(res, 404, { error: 'Session not found' }); sendJson(res, 200, { session: serializeSession(session) }); }
+async function handleGetSessionTodos(_req, res, sessionId, userId) {
+ const session = getSession(sessionId, userId);
+ if (!session) return sendJson(res, 404, { error: 'Session not found' });
+
+ // Get all todos from messages in the session
+ const allTodos = [];
+ if (session.messages) {
+ for (const msg of session.messages) {
+ if (msg.todos && Array.isArray(msg.todos)) {
+ // Add message ID to each todo for reference
+ const todosWithMeta = msg.todos.map(todo => ({
+ ...todo,
+ messageId: msg.id,
+ messageStatus: msg.status
+ }));
+ allTodos.push(...todosWithMeta);
+ }
+ }
+ }
+
+ sendJson(res, 200, { todos: allTodos });
+}
+
async function handleNewMessage(req, res, sessionId, userId) {
const session = getSession(sessionId, userId);
if (!session) return sendJson(res, 404, { error: 'Session not found' });
@@ -15112,6 +15165,13 @@ async function handleUndoMessage(req, res, sessionId, messageId, userId) {
timeout: 30000
});
+ // Clear todos from the message when undone
+ if (message.todos) {
+ const todoCount = message.todos.length;
+ message.todos = [];
+ log('Cleared todos from undone message', { sessionId, messageId, clearedCount: todoCount });
+ }
+
log('Undo command completed', { sessionId, messageId });
sendJson(res, 200, { ok: true, message: 'Undo command sent successfully' });
} catch (error) {
@@ -15148,6 +15208,13 @@ async function handleRedoMessage(req, res, sessionId, messageId, userId) {
timeout: 30000
});
+ // Clear todos from the message when redone (they will be regenerated during the new execution)
+ if (message.todos) {
+ const todoCount = message.todos.length;
+ message.todos = [];
+ log('Cleared todos from redone message', { sessionId, messageId, clearedCount: todoCount });
+ }
+
log('Redo command completed', { sessionId, messageId });
sendJson(res, 200, { ok: true, message: 'Redo command sent successfully' });
} catch (error) {
@@ -16320,6 +16387,15 @@ async function routeInternal(req, res, url, pathname) {
if (!userId) return;
return handleGetSession(req, res, sessionMatch[1], userId);
}
+
+ // GET todos for a session
+ const todosMatch = pathname.match(/^\/api\/sessions\/([a-f0-9\-]+)\/todos$/i);
+ if (req.method === 'GET' && todosMatch) {
+ const userId = requireUserId(req, res, url);
+ if (!userId) return;
+ return handleGetSessionTodos(req, res, todosMatch[1], userId);
+ }
+
const messageMatch = pathname.match(/^\/api\/sessions\/([a-f0-9\-]+)\/messages$/i);
if (req.method === 'POST' && messageMatch) {
const userId = requireUserId(req, res, url);