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