Fix progress UI: incremental updates to prevent flicker, proper scroll

- Rewrite renderManifestProgress to update existing DOM elements
- Only rebuild HTML on first render, then update incrementally
- Add updateStatusSection, updateProgressTree, updateItems functions
- Fix scroll: only task list scrolls, detail panel is fixed
- Add status-indicator, status-gear CSS styles
- Progress content has max-height with scroll, terminal is fixed size
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-01 10:13:38 -03:00
parent 9f922b523d
commit 6bbedd6645
3 changed files with 319 additions and 208 deletions

View file

@ -147,6 +147,28 @@
.status-time {
font-size: 13px;
color: var(--text-tertiary, #666);
display: flex;
align-items: center;
gap: 6px;
}
.status-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent, #c5f82a);
animation: dot-pulse 1.5s ease-in-out infinite;
}
.status-gear {
font-size: 14px;
color: var(--text-muted, #444);
cursor: pointer;
transition: color 0.2s;
}
.status-gear:hover {
color: var(--accent, #c5f82a);
}
.status-dot {
@ -179,7 +201,11 @@
/* PROGRESS LOG Section */
.taskmd-progress-content {
background: var(--bg, #0a0a0a);
flex-shrink: 0;
flex: 1;
min-height: 0;
max-height: 350px;
overflow-y: auto;
overflow-x: hidden;
}
.taskmd-tree {
@ -434,11 +460,11 @@
/* TERMINAL Section */
.taskmd-terminal {
flex: 1;
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 150px;
max-height: 300px;
min-height: 120px;
max-height: 200px;
overflow: hidden;
}
@ -582,9 +608,43 @@
.taskmd-actions {
display: flex;
gap: 12px;
padding: 20px 24px;
padding: 16px 24px;
border-top: 1px solid var(--border, #1a1a1a);
background: var(--bg-secondary, #0d0d0d);
flex-shrink: 0;
}
.taskmd-actions .btn-action-rich {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.taskmd-actions .btn-open-app {
background: var(--accent, #c5f82a);
color: var(--bg, #0a0a0a);
}
.taskmd-actions .btn-open-app:hover {
filter: brightness(1.1);
}
.taskmd-actions .btn-cancel {
background: transparent;
border: 1px solid var(--border, #333);
color: var(--text-secondary, #888);
}
.taskmd-actions .btn-cancel:hover {
border-color: var(--error, #ef4444);
color: var(--error, #ef4444);
}
/* Empty State */

View file

@ -275,6 +275,7 @@
grid-template-columns: 30% 70%;
flex: 1;
overflow: hidden;
height: 100%;
}
/* =============================================================================
@ -286,11 +287,13 @@
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--border, #2a2a2a);
height: 100%;
}
.tasks-list-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px;
display: flex;
flex-direction: column;
@ -811,6 +814,15 @@
overflow: hidden;
height: 100%;
min-height: 0;
position: relative;
}
/* Task detail content - NO scroll on main container */
#task-detail-content {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* Detail Header */
@ -3118,12 +3130,31 @@
.task-detail-rich {
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px;
gap: 0;
padding: 0;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* Sections inside task detail - fixed height, no scroll */
.task-detail-rich .taskmd-header,
.task-detail-rich .taskmd-section {
flex-shrink: 0;
}
/* Only terminal output scrolls */
.task-detail-rich .taskmd-terminal-output {
flex: 1;
min-height: 100px;
max-height: none;
overflow-y: auto;
}
/* Progress content has fixed max height with scroll */
.task-detail-rich .taskmd-progress-content {
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
}
.detail-section-box {

View file

@ -459,265 +459,285 @@ function handleWebSocketMessage(data) {
const pendingManifestUpdates = new Map();
function renderManifestProgress(taskId, manifest, retryCount = 0) {
console.log(
"[Manifest] renderManifestProgress called for task:",
taskId,
"sections:",
manifest?.sections?.length,
"retry:",
retryCount,
);
// Only update if this is the selected task
if (TasksState.selectedTaskId !== taskId) {
return;
}
// Try multiple selectors to find the progress log element
let progressLog = document.getElementById(`progress-log-${taskId}`);
if (!progressLog) {
progressLog = document.querySelector(".taskmd-progress-content");
}
if (!progressLog) {
progressLog = document.querySelector(".progress-summary-content");
}
if (!progressLog) {
console.warn("[Manifest] No progress log element found for task:", taskId);
// Try to find any visible task detail panel
const detailPanel = document.querySelector(".task-detail-rich");
if (detailPanel) {
const taskDetailId = detailPanel.dataset.taskId;
console.log("[Manifest] Found detail panel for task:", taskDetailId);
if (taskDetailId === taskId) {
progressLog = detailPanel.querySelector(".taskmd-progress-content");
}
}
if (!progressLog) {
// If task is selected but element not yet loaded, retry after a delay
if (TasksState.selectedTaskId === taskId && retryCount < 5) {
console.log(
"[Manifest] Element not ready, scheduling retry",
retryCount + 1,
);
if (retryCount < 5) {
pendingManifestUpdates.set(taskId, manifest);
setTimeout(
() => {
const pending = pendingManifestUpdates.get(taskId);
if (pending) {
if (pending && TasksState.selectedTaskId === taskId) {
renderManifestProgress(taskId, pending, retryCount + 1);
}
},
200 * (retryCount + 1),
150 * (retryCount + 1),
);
}
return;
}
}
// Clear pending update since we found the element
// Clear pending update
pendingManifestUpdates.delete(taskId);
if (!manifest || !manifest.sections) {
console.log("[Manifest] No sections in manifest");
return;
}
console.log("[Manifest] Rendering", manifest.sections.length, "sections");
// Get total steps from manifest progress
const totalSteps = manifest.progress?.total || 60;
// Update STATUS section if exists
updateStatusSection(manifest);
// Update or create progress tree
let tree = progressLog.querySelector(".taskmd-tree");
if (!tree) {
// First render - create full HTML
progressLog.innerHTML = buildProgressTreeHTML(manifest, totalSteps);
// Auto-expand running sections
progressLog
.querySelectorAll(".tree-section.running, .tree-child.running")
.forEach((el) => {
el.classList.add("expanded");
});
} else {
// Incremental update - only update changed elements
updateProgressTree(tree, manifest, totalSteps);
}
// Update terminal stats
updateTerminalStats(taskId, manifest);
}
function updateStatusSection(manifest) {
const statusContent = document.querySelector(".taskmd-status-content");
if (!statusContent) return;
// Update current action
const actionText = statusContent.querySelector(
".status-current .status-text",
);
if (actionText && manifest.status?.current_action) {
actionText.textContent = manifest.status.current_action;
}
// Update runtime
const runtimeEl = statusContent.querySelector(".status-main .status-time");
if (runtimeEl && manifest.status?.runtime_display) {
runtimeEl.innerHTML = `Runtime: ${manifest.status.runtime_display} <span class="status-indicator"></span>`;
}
// Update estimated
const estimatedEl = statusContent.querySelector(
".status-current .status-time",
);
if (estimatedEl && manifest.status?.estimated_display) {
estimatedEl.innerHTML = `Estimated: ${manifest.status.estimated_display} <span class="status-gear">⚙</span>`;
}
}
function buildProgressTreeHTML(manifest, totalSteps) {
let html = '<div class="taskmd-tree">';
for (const section of manifest.sections) {
const statusClass = section.status
? section.status.toLowerCase()
: "pending";
// Use global step count (e.g., "Step 24/60")
const statusClass = section.status?.toLowerCase() || "pending";
const isRunning = statusClass === "running";
const globalCurrent =
section.progress?.global_current || section.progress?.current || 0;
const globalStart = section.progress?.global_start || 0;
const statusText = section.status || "Pending";
// Calculate section progress percentage
const sectionCurrent = section.progress?.current || 0;
const sectionTotal = section.progress?.total || 1;
const sectionPercent = Math.round((sectionCurrent / sectionTotal) * 100);
const showProgress = statusClass === "running" && sectionTotal > 1;
html += `
<div class="tree-section ${statusClass}" data-section-id="${section.id}">
<div class="tree-section ${statusClass}${isRunning ? " expanded" : ""}" data-section-id="${section.id}">
<div class="tree-row tree-level-0" onclick="this.parentElement.classList.toggle('expanded')">
<span class="tree-name">${escapeHtml(section.name)}</span>
<span class="tree-view-details">View Details </span>
<span class="tree-step-badge">Step ${globalCurrent}/${totalSteps}</span>
<span class="tree-status ${statusClass}">${statusText}</span>
</div>
${
showProgress
? `
<div class="tree-progress-bar-container">
<div class="tree-progress-bar" style="width: ${sectionPercent}%"></div>
<span class="tree-progress-percent">${sectionPercent}%</span>
</div>`
: ""
}
<div class="tree-children">`;
// Render children if present
if (section.children && section.children.length > 0) {
// Children
if (section.children?.length > 0) {
for (const child of section.children) {
const childStatus = child.status
? child.status.toLowerCase()
: "pending";
const childStepCurrent = child.progress?.current || 0;
const childStepTotal = child.progress?.total || 1;
const childStatusText = child.status || "Pending";
const childStatus = child.status?.toLowerCase() || "pending";
const childIsRunning = childStatus === "running";
html += `
<div class="tree-child ${childStatus}" onclick="this.classList.toggle('expanded')">
<div class="tree-row tree-level-1">
<div class="tree-child ${childStatus}${childIsRunning ? " expanded" : ""}" data-child-id="${child.id}">
<div class="tree-row tree-level-1" onclick="this.parentElement.classList.toggle('expanded')">
<span class="tree-indent"></span>
<span class="tree-name">${escapeHtml(child.name)}</span>
<span class="tree-view-details">View Details </span>
<span class="tree-step-badge">Step ${childStepCurrent}/${childStepTotal}</span>
<span class="tree-status ${childStatus}">${childStatusText}</span>
<span class="tree-step-badge">Step ${child.progress?.current || 0}/${child.progress?.total || 1}</span>
<span class="tree-status ${childStatus}">${child.status || "Pending"}</span>
</div>
<div class="tree-items">`;
// Render item_groups first (grouped fields like "email, password_hash, email_verified")
if (child.item_groups && child.item_groups.length > 0) {
for (const group of child.item_groups) {
const groupStatus = group.status
? group.status.toLowerCase()
: "pending";
const checkIcon = groupStatus === "completed" ? "✓" : "";
const duration = group.duration_seconds
? group.duration_seconds >= 60
? `Duration: ${Math.floor(group.duration_seconds / 60)} min`
: `Duration: ${group.duration_seconds} sec`
: "";
html += `
<div class="tree-item ${groupStatus}">
<span class="tree-item-dot ${groupStatus}"></span>
<span class="tree-item-name">${escapeHtml(group.name)}</span>
<span class="tree-item-duration">${duration}</span>
<span class="tree-item-check ${groupStatus}">${checkIcon}</span>
</div>`;
}
}
// Then render individual items
if (child.items && child.items.length > 0) {
for (const item of child.items) {
const itemStatus = item.status
? item.status.toLowerCase()
: "pending";
const checkIcon = itemStatus === "completed" ? "✓" : "";
const duration = item.duration_seconds
? item.duration_seconds >= 60
? `Duration: ${Math.floor(item.duration_seconds / 60)} min`
: `Duration: ${item.duration_seconds} sec`
: "";
html += `
<div class="tree-item ${itemStatus}">
<span class="tree-item-dot ${itemStatus}"></span>
<span class="tree-item-name">${escapeHtml(item.name)}</span>
<span class="tree-item-duration">${duration}</span>
<span class="tree-item-check ${itemStatus}">${checkIcon}</span>
</div>`;
}
// Items
const items = [...(child.item_groups || []), ...(child.items || [])];
for (const item of items) {
html += buildItemHTML(item);
}
html += `</div></div>`;
}
}
// Render section-level item_groups
if (section.item_groups && section.item_groups.length > 0) {
for (const group of section.item_groups) {
const groupStatus = group.status
? group.status.toLowerCase()
: "pending";
const checkIcon = groupStatus === "completed" ? "✓" : "";
const duration = group.duration_seconds
? group.duration_seconds >= 60
? `Duration: ${Math.floor(group.duration_seconds / 60)} min`
: `Duration: ${group.duration_seconds} sec`
: "";
html += `
<div class="tree-item ${groupStatus}">
<span class="tree-item-dot ${groupStatus}"></span>
<span class="tree-item-name">${escapeHtml(group.name)}</span>
<span class="tree-item-duration">${duration}</span>
<span class="tree-item-check ${groupStatus}">${checkIcon}</span>
</div>`;
}
}
// Render section-level items
if (section.items && section.items.length > 0) {
for (const item of section.items) {
const itemStatus = item.status ? item.status.toLowerCase() : "pending";
const checkIcon = itemStatus === "completed" ? "✓" : "";
const duration = item.duration_seconds
? item.duration_seconds >= 60
? `Duration: ${Math.floor(item.duration_seconds / 60)} min`
: `Duration: ${item.duration_seconds} sec`
: "";
html += `
<div class="tree-item ${itemStatus}">
<span class="tree-item-dot ${itemStatus}"></span>
<span class="tree-item-name">${escapeHtml(item.name)}</span>
<span class="tree-item-duration">${duration}</span>
<span class="tree-item-check ${itemStatus}">${checkIcon}</span>
</div>`;
}
// Section-level items
const sectionItems = [
...(section.item_groups || []),
...(section.items || []),
];
for (const item of sectionItems) {
html += buildItemHTML(item);
}
html += `</div></div>`;
}
html += "</div>";
return html;
}
progressLog.innerHTML = html;
function buildItemHTML(item) {
const status = item.status?.toLowerCase() || "pending";
const checkIcon = status === "completed" ? "✓" : "";
const duration = item.duration_seconds
? item.duration_seconds >= 60
? `Duration: ${Math.floor(item.duration_seconds / 60)} min`
: `Duration: ${item.duration_seconds} sec`
: "";
const name = item.name || item.display_name || "";
// Also update the STATUS section if it exists
const statusSection = document.querySelector(".taskmd-status-content");
if (statusSection && manifest.status) {
const runtime = manifest.status.runtime_display || "calculating...";
const estimated = manifest.status.estimated_display || "calculating...";
const currentAction = manifest.status.current_action || "Processing...";
return `
<div class="tree-item ${status}" data-item-id="${item.id || name}">
<span class="tree-item-dot ${status}"></span>
<span class="tree-item-name">${escapeHtml(name)}</span>
<span class="tree-item-duration">${duration}</span>
<span class="tree-item-check ${status}">${checkIcon}</span>
</div>`;
}
statusSection.innerHTML = `
<div class="status-row status-main">
<span class="status-title">${escapeHtml(manifest.status.title || manifest.app_name || "")}</span>
<span class="status-time">Runtime: ${runtime}</span>
</div>
<div class="status-row status-current">
<span class="status-dot active"></span>
<span class="status-text">${escapeHtml(currentAction)}</span>
<span class="status-time">Estimated: ${estimated}</span>
</div>
`;
}
function updateProgressTree(tree, manifest, totalSteps) {
for (const section of manifest.sections) {
const sectionEl = tree.querySelector(`[data-section-id="${section.id}"]`);
if (!sectionEl) continue;
// Update terminal stats if they exist
const processedEl = document.getElementById(`terminal-processed-${taskId}`);
if (processedEl && manifest.terminal?.stats) {
processedEl.textContent = manifest.terminal.stats.processed || "0";
}
const statusClass = section.status?.toLowerCase() || "pending";
const globalCurrent =
section.progress?.global_current || section.progress?.current || 0;
console.log(
"[Manifest] Rendered progress for task:",
taskId,
"completed:",
manifest.progress?.current,
"/",
manifest.progress?.total,
// Update section class
sectionEl.className = `tree-section ${statusClass}${statusClass === "running" ? " expanded" : sectionEl.classList.contains("expanded") ? " expanded" : ""}`;
// Update step badge
const stepBadge = sectionEl.querySelector(
":scope > .tree-row .tree-step-badge",
);
if (stepBadge)
stepBadge.textContent = `Step ${globalCurrent}/${totalSteps}`;
// Update status text
const statusEl = sectionEl.querySelector(":scope > .tree-row .tree-status");
if (statusEl) {
statusEl.className = `tree-status ${statusClass}`;
statusEl.textContent = section.status || "Pending";
}
// Update children
if (section.children) {
for (const child of section.children) {
const childEl = sectionEl.querySelector(
`[data-child-id="${child.id}"]`,
);
if (!childEl) continue;
const childStatus = child.status?.toLowerCase() || "pending";
childEl.className = `tree-child ${childStatus}${childStatus === "running" ? " expanded" : childEl.classList.contains("expanded") ? " expanded" : ""}`;
const childStepBadge = childEl.querySelector(
":scope > .tree-row .tree-step-badge",
);
if (childStepBadge)
childStepBadge.textContent = `Step ${child.progress?.current || 0}/${child.progress?.total || 1}`;
const childStatusEl = childEl.querySelector(
":scope > .tree-row .tree-status",
);
if (childStatusEl) {
childStatusEl.className = `tree-status ${childStatus}`;
childStatusEl.textContent = child.status || "Pending";
}
// Update items
updateItems(childEl.querySelector(".tree-items"), [
...(child.item_groups || []),
...(child.items || []),
]);
}
}
// Update section-level items
updateItems(sectionEl.querySelector(".tree-children"), [
...(section.item_groups || []),
...(section.items || []),
]);
}
}
function updateItems(container, items) {
if (!container || !items) return;
for (const item of items) {
const itemId = item.id || item.name || item.display_name;
const itemEl = container.querySelector(`[data-item-id="${itemId}"]`);
if (!itemEl) {
// New item - append it
container.insertAdjacentHTML("beforeend", buildItemHTML(item));
continue;
}
const status = item.status?.toLowerCase() || "pending";
itemEl.className = `tree-item ${status}`;
const dot = itemEl.querySelector(".tree-item-dot");
if (dot) dot.className = `tree-item-dot ${status}`;
const check = itemEl.querySelector(".tree-item-check");
if (check) {
check.className = `tree-item-check ${status}`;
check.textContent = status === "completed" ? "✓" : "";
}
const durationEl = itemEl.querySelector(".tree-item-duration");
if (durationEl && item.duration_seconds) {
durationEl.textContent =
item.duration_seconds >= 60
? `Duration: ${Math.floor(item.duration_seconds / 60)} min`
: `Duration: ${item.duration_seconds} sec`;
}
}
}
function updateTerminalStats(taskId, manifest) {
const processedEl = document.getElementById(`terminal-processed-${taskId}`);
if (processedEl && manifest.terminal?.stats?.processed) {
processedEl.textContent = manifest.terminal.stats.processed;
}
const etaEl = document.getElementById(`terminal-eta-${taskId}`);
if (etaEl && manifest.terminal?.stats?.eta) {
etaEl.textContent = manifest.terminal.stats.eta;
}
}
function escapeHtml(text) {