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