Fix duplicate WS progress bug - rebuild tree on structure change

- Normalize task ID comparison in retry logic for consistent lookups
- Use normalized keys in pendingManifestUpdates Map
- Skip manifest_update in ProgressPanel (already handled by tasks.js)
- Clean up existing handler before registering new one in ProgressPanel.init
- Add name-based fallback lookups for sections/children/items when IDs change
- Detect structure changes (section count, children, items, names) and rebuild tree
- Clear progress-empty placeholder before rendering tree
- Add detailed BUILD_TREE logging for debugging
- Add cache-busting version to tasks.js script tag
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-02 12:48:54 -03:00
parent 4499bcda7a
commit b51c542afa
8 changed files with 1659 additions and 573 deletions

View file

@ -246,8 +246,6 @@ struct WsQuery {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct OptionalWsQuery { struct OptionalWsQuery {
session_id: Option<String>,
user_id: Option<String>,
task_id: Option<String>, task_id: Option<String>,
} }
@ -362,8 +360,23 @@ async fn handle_task_progress_ws_proxy(
while let Some(msg) = backend_rx.next().await { while let Some(msg) = backend_rx.next().await {
match msg { match msg {
Ok(TungsteniteMessage::Text(text)) => { Ok(TungsteniteMessage::Text(text)) => {
if client_tx.send(AxumMessage::Text(text)).await.is_err() { // Log manifest_update messages for debugging
break; let is_manifest = text.contains("manifest_update");
if is_manifest {
info!("[WS_PROXY] Forwarding manifest_update to client: {}...", &text[..text.len().min(200)]);
} else if text.contains("task_progress") {
debug!("[WS_PROXY] Forwarding task_progress to client");
}
match client_tx.send(AxumMessage::Text(text)).await {
Ok(()) => {
if is_manifest {
info!("[WS_PROXY] manifest_update SENT successfully to client");
}
}
Err(e) => {
error!("[WS_PROXY] Failed to send message to client: {:?}", e);
break;
}
} }
} }
Ok(TungsteniteMessage::Binary(data)) => { Ok(TungsteniteMessage::Binary(data)) => {
@ -529,6 +542,18 @@ fn create_ui_router() -> Router<AppState> {
Router::new().fallback(any(proxy_api)) Router::new().fallback(any(proxy_api))
} }
async fn serve_favicon() -> impl IntoResponse {
let favicon_path = PathBuf::from("./ui/suite/public/favicon.ico");
match tokio::fs::read(&favicon_path).await {
Ok(bytes) => (
StatusCode::OK,
[("content-type", "image/x-icon")],
bytes,
).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
fn add_static_routes(router: Router<AppState>, suite_path: &Path) -> Router<AppState> { fn add_static_routes(router: Router<AppState>, suite_path: &Path) -> Router<AppState> {
let mut r = router; let mut r = router;
@ -554,7 +579,8 @@ pub fn configure_router() -> Router {
.nest("/apps", create_apps_router()) .nest("/apps", create_apps_router())
.route("/", get(index)) .route("/", get(index))
.route("/minimal", get(serve_minimal)) .route("/minimal", get(serve_minimal))
.route("/suite", get(serve_suite)); .route("/suite", get(serve_suite))
.route("/favicon.ico", get(serve_favicon));
router = add_static_routes(router, &suite_path); router = add_static_routes(router, &suite_path);

View file

@ -968,7 +968,7 @@
<!-- Core scripts --> <!-- Core scripts -->
<script src="js/theme-manager.js"></script> <script src="js/theme-manager.js"></script>
<script src="js/htmx-app.js"></script> <script src="js/htmx-app.js"></script>
<script src="tasks/tasks.js"></script> <script src="tasks/tasks.js?v=20260102"></script>
<!-- Application initialization --> <!-- Application initialization -->
<script> <script>

BIN
ui/suite/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -178,40 +178,36 @@ function initWebSocket() {
} }
function initTaskProgressWebSocket() { function initTaskProgressWebSocket() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; // Use the singleton WebSocket from tasks.js instead of creating a duplicate connection
const wsUrl = `${protocol}//${window.location.host}/ws/task-progress`; // This prevents the "2 receivers" problem where manifest_update events go to one
// WebSocket while the browser UI is listening on a different one
try { console.log("[AutoTask] Using singleton WebSocket for task progress");
AutoTaskState.progressWsConnection = new WebSocket(wsUrl);
AutoTaskState.progressWsConnection.onopen = function () { // Create handler for task progress messages
console.log("Task Progress WebSocket connected"); const handler = function (data) {
}; handleTaskProgressMessage(data);
};
AutoTaskState.progressWsConnection.onmessage = function (event) { // Store handler reference for cleanup
handleTaskProgressMessage(JSON.parse(event.data)); AutoTaskState._progressHandler = handler;
};
AutoTaskState.progressWsConnection.onclose = function () { // Register with the global singleton WebSocket from tasks.js
console.log("Task Progress WebSocket disconnected, reconnecting..."); if (typeof registerTaskProgressHandler === "function") {
setTimeout(initTaskProgressWebSocket, 3000); registerTaskProgressHandler(handler);
}; console.log("[AutoTask] Registered with singleton WebSocket");
} else {
AutoTaskState.progressWsConnection.onerror = function (error) { // Fallback: wait for tasks.js to load and retry
console.error("Task Progress WebSocket error:", error); console.log("[AutoTask] Waiting for tasks.js singleton to be available...");
}; setTimeout(initTaskProgressWebSocket, 500);
} catch (e) {
console.warn("Task Progress WebSocket not available");
} }
} }
function handleTaskProgressMessage(data) { function handleTaskProgressMessage(data) {
// Forward to ProgressPanel if available // Note: ProgressPanel now registers its own handler with the singleton,
if (typeof ProgressPanel !== "undefined" && ProgressPanel.manifest) { // so we don't need to forward messages manually here
ProgressPanel.handleProgressUpdate(data);
}
console.log("Task progress:", data); console.log("[AutoTask] Task progress:", data.type, data.task_id);
switch (data.type) { switch (data.type) {
case "connected": case "connected":

View file

@ -1,197 +1,254 @@
const ProgressPanel = { const ProgressPanel = {
manifest: null, manifest: null,
wsConnection: null, wsConnection: null, // Deprecated - now uses singleton from tasks.js
startTime: null, startTime: null,
runtimeInterval: null, runtimeInterval: null,
_boundHandler: null, // Store bound handler for cleanup
init(taskId) { init(taskId) {
this.taskId = taskId; // Clean up any existing handler before registering a new one
this.startTime = Date.now(); // This prevents duplicate handlers if init is called multiple times
this.startRuntimeCounter(); if (
this.connectWebSocket(taskId); this._boundHandler &&
}, typeof unregisterTaskProgressHandler === "function"
) {
unregisterTaskProgressHandler(this._boundHandler);
this._boundHandler = null;
}
connectWebSocket(taskId) { this.taskId = taskId;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; this.startTime = Date.now();
const wsUrl = `${protocol}//${window.location.host}/ws/task-progress/${taskId}`; this.startRuntimeCounter();
this.connectWebSocket(taskId);
},
this.wsConnection = new WebSocket(wsUrl); connectWebSocket(taskId) {
// Instead of creating our own WebSocket, register with the singleton from tasks.js
// This prevents the "2 receivers" problem where manifest_update goes to one connection
// while the browser UI is listening on another
this.wsConnection.onopen = () => { console.log("[ProgressPanel] Using singleton WebSocket for task:", taskId);
console.log('Progress panel WebSocket connected');
};
this.wsConnection.onmessage = (event) => { // Create bound handler that filters for our task
const data = JSON.parse(event.data); this._boundHandler = (data) => {
this.handleProgressUpdate(data); // Only process messages for our task
}; if (data.task_id && String(data.task_id) !== String(taskId)) {
return;
}
this.handleProgressUpdate(data);
};
this.wsConnection.onclose = () => { // Register with the global singleton WebSocket
console.log('Progress panel WebSocket closed'); if (typeof registerTaskProgressHandler === "function") {
setTimeout(() => this.connectWebSocket(taskId), 3000); registerTaskProgressHandler(this._boundHandler);
}; console.log("[ProgressPanel] Registered with singleton WebSocket");
} else {
// Fallback: wait for tasks.js to load and retry
console.log(
"[ProgressPanel] Waiting for tasks.js singleton to be available...",
);
setTimeout(() => this.connectWebSocket(taskId), 500);
}
},
this.wsConnection.onerror = (error) => { handleProgressUpdate(data) {
console.error('Progress panel WebSocket error:', error); // Skip manifest_update - already handled by tasks.js renderManifestProgress()
}; // Processing it here would cause duplicate updates and race conditions
}, if (
data.type === "manifest_update" ||
data.event_type === "manifest_update"
) {
// Don't process here - tasks.js handles this via handleWebSocketMessage()
// which calls renderManifestProgress() with proper normalized ID handling
return;
}
handleProgressUpdate(data) { if (data.type === "section_update") {
if (data.type === 'manifest_update') { this.updateSection(data.section_id, data.status, data.progress);
this.manifest = data.manifest; } else if (data.type === "item_update") {
this.render(); this.updateItem(
} else if (data.type === 'section_update') { data.section_id,
this.updateSection(data.section_id, data.status, data.progress); data.item_id,
} else if (data.type === 'item_update') { data.status,
this.updateItem(data.section_id, data.item_id, data.status, data.duration); data.duration,
} else if (data.type === 'terminal_line') { );
this.addTerminalLine(data.content, data.line_type); } else if (data.type === "terminal_line") {
} else if (data.type === 'stats_update') { this.addTerminalLine(data.content, data.line_type);
this.updateStats(data.stats); } else if (data.type === "stats_update") {
} else if (data.type === 'task_progress') { this.updateStats(data.stats);
this.handleTaskProgress(data); } else if (data.type === "task_progress") {
this.handleTaskProgress(data);
}
},
handleTaskProgress(data) {
// Check for manifest in activity
if (data.activity && data.activity.manifest) {
this.manifest = data.activity.manifest;
this.render();
}
// Also check for manifest in details (manifest_update events)
if (
data.details &&
(data.step === "manifest_update" || data.event_type === "manifest_update")
) {
try {
const parsed =
typeof data.details === "string"
? JSON.parse(data.details)
: data.details;
if (parsed && parsed.sections) {
this.manifest = parsed;
this.render();
} }
}, } catch (e) {
// Not a manifest JSON, might be terminal output
console.debug("Details is not manifest JSON:", e.message);
}
}
handleTaskProgress(data) { if (data.step && data.step !== "manifest_update") {
if (data.activity && data.activity.manifest) { this.updateCurrentAction(data.message || data.step);
this.manifest = data.activity.manifest; }
this.render();
}
if (data.step) { // Only add non-manifest details as terminal lines
this.updateCurrentAction(data.message || data.step); if (
} data.details &&
data.step !== "manifest_update" &&
data.event_type !== "manifest_update"
) {
this.addTerminalLine(data.details, "info");
}
},
if (data.details) { startRuntimeCounter() {
this.addTerminalLine(data.details, 'info'); this.runtimeInterval = setInterval(() => {
} const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
}, const runtimeEl = document.getElementById("status-runtime");
if (runtimeEl) {
runtimeEl.textContent = this.formatDuration(elapsed);
}
}, 1000);
},
startRuntimeCounter() { stopRuntimeCounter() {
this.runtimeInterval = setInterval(() => { if (this.runtimeInterval) {
const elapsed = Math.floor((Date.now() - this.startTime) / 1000); clearInterval(this.runtimeInterval);
const runtimeEl = document.getElementById('status-runtime'); this.runtimeInterval = null;
if (runtimeEl) { }
runtimeEl.textContent = this.formatDuration(elapsed); },
formatDuration(seconds) {
if (seconds < 60) {
return `${seconds} sec`;
} else if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return `${mins} min`;
} else {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${hours} hr ${mins} min`;
}
},
render() {
if (!this.manifest) return;
this.renderStatus();
this.renderProgressLog();
this.renderTerminal();
},
renderStatus() {
const titleEl = document.getElementById("status-title");
if (titleEl) {
titleEl.textContent = this.manifest.description || this.manifest.app_name;
}
const estimatedEl = document.getElementById("estimated-time");
if (estimatedEl && this.manifest.estimated_seconds) {
estimatedEl.textContent = this.formatDuration(
this.manifest.estimated_seconds,
);
}
const currentAction = this.getCurrentAction();
const actionEl = document.getElementById("current-action");
if (actionEl && currentAction) {
actionEl.textContent = currentAction;
}
this.updateDecisionPoint();
},
getCurrentAction() {
if (!this.manifest || !this.manifest.sections) return null;
for (const section of this.manifest.sections) {
if (section.status === "Running") {
for (const child of section.children || []) {
if (child.status === "Running") {
for (const item of child.items || []) {
if (item.status === "Running") {
return item.name;
}
} }
}, 1000); return child.name;
}, }
stopRuntimeCounter() {
if (this.runtimeInterval) {
clearInterval(this.runtimeInterval);
this.runtimeInterval = null;
} }
}, return section.name;
}
}
return null;
},
formatDuration(seconds) { updateCurrentAction(action) {
if (seconds < 60) { const actionEl = document.getElementById("current-action");
return `${seconds} sec`; if (actionEl) {
} else if (seconds < 3600) { actionEl.textContent = action;
const mins = Math.floor(seconds / 60); }
return `${mins} min`; },
} else {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${hours} hr ${mins} min`;
}
},
render() { updateDecisionPoint() {
if (!this.manifest) return; const decisionStepEl = document.getElementById("decision-step");
const decisionTotalEl = document.getElementById("decision-total");
this.renderStatus(); if (decisionStepEl && this.manifest) {
this.renderProgressLog(); decisionStepEl.textContent = this.manifest.completed_steps || 0;
this.renderTerminal(); }
}, if (decisionTotalEl && this.manifest) {
decisionTotalEl.textContent = this.manifest.total_steps || 0;
}
},
renderStatus() { renderProgressLog() {
const titleEl = document.getElementById('status-title'); const container = document.getElementById("progress-log-content");
if (titleEl) { if (!container || !this.manifest || !this.manifest.sections) return;
titleEl.textContent = this.manifest.description || this.manifest.app_name;
}
const estimatedEl = document.getElementById('estimated-time'); container.innerHTML = "";
if (estimatedEl && this.manifest.estimated_seconds) {
estimatedEl.textContent = this.formatDuration(this.manifest.estimated_seconds);
}
const currentAction = this.getCurrentAction(); for (const section of this.manifest.sections) {
const actionEl = document.getElementById('current-action'); const sectionEl = this.createSectionElement(section);
if (actionEl && currentAction) { container.appendChild(sectionEl);
actionEl.textContent = currentAction; }
} },
this.updateDecisionPoint(); createSectionElement(section) {
}, const sectionDiv = document.createElement("div");
sectionDiv.className = "log-section";
sectionDiv.dataset.sectionId = section.id;
getCurrentAction() { if (section.status === "Running" || section.status === "Completed") {
if (!this.manifest || !this.manifest.sections) return null; sectionDiv.classList.add("expanded");
}
for (const section of this.manifest.sections) { const statusClass = section.status.toLowerCase();
if (section.status === 'Running') { // Support both direct fields and nested progress object
for (const child of section.children || []) { const stepCurrent = section.current_step ?? section.progress?.current ?? 0;
if (child.status === 'Running') { const stepTotal = section.total_steps ?? section.progress?.total ?? 0;
for (const item of child.items || []) {
if (item.status === 'Running') {
return item.name;
}
}
return child.name;
}
}
return section.name;
}
}
return null;
},
updateCurrentAction(action) { sectionDiv.innerHTML = `
const actionEl = document.getElementById('current-action');
if (actionEl) {
actionEl.textContent = action;
}
},
updateDecisionPoint() {
const decisionStepEl = document.getElementById('decision-step');
const decisionTotalEl = document.getElementById('decision-total');
if (decisionStepEl && this.manifest) {
decisionStepEl.textContent = this.manifest.completed_steps || 0;
}
if (decisionTotalEl && this.manifest) {
decisionTotalEl.textContent = this.manifest.total_steps || 0;
}
},
renderProgressLog() {
const container = document.getElementById('progress-log-content');
if (!container || !this.manifest || !this.manifest.sections) return;
container.innerHTML = '';
for (const section of this.manifest.sections) {
const sectionEl = this.createSectionElement(section);
container.appendChild(sectionEl);
}
},
createSectionElement(section) {
const sectionDiv = document.createElement('div');
sectionDiv.className = 'log-section';
sectionDiv.dataset.sectionId = section.id;
if (section.status === 'Running' || section.status === 'Completed') {
sectionDiv.classList.add('expanded');
}
const statusClass = section.status.toLowerCase();
const stepCurrent = section.current_step || 0;
const stepTotal = section.total_steps || 0;
sectionDiv.innerHTML = `
<div class="log-section-header" onclick="ProgressPanel.toggleSection('${section.id}')"> <div class="log-section-header" onclick="ProgressPanel.toggleSection('${section.id}')">
<span class="section-indicator ${statusClass}"></span> <span class="section-indicator ${statusClass}"></span>
<span class="section-name">${this.escapeHtml(section.name)}</span> <span class="section-name">${this.escapeHtml(section.name)}</span>
@ -205,38 +262,45 @@ const ProgressPanel = {
</div> </div>
`; `;
const childrenContainer = sectionDiv.querySelector('.log-children'); const childrenContainer = sectionDiv.querySelector(".log-children");
for (const child of section.children || []) { for (const child of section.children || []) {
const childEl = this.createChildElement(child, section.id); const childEl = this.createChildElement(child, section.id);
childrenContainer.appendChild(childEl); childrenContainer.appendChild(childEl);
} }
if (section.items && section.items.length > 0 && (!section.children || section.children.length === 0)) { if (
for (const item of section.items) { section.items &&
const itemEl = this.createItemElement(item); section.items.length > 0 &&
childrenContainer.appendChild(itemEl); (!section.children || section.children.length === 0)
} ) {
} for (const item of section.items) {
const itemEl = this.createItemElement(item);
childrenContainer.appendChild(itemEl);
}
}
return sectionDiv; return sectionDiv;
}, },
createChildElement(child, parentId) { createChildElement(child, parentId) {
const childDiv = document.createElement('div'); const childDiv = document.createElement("div");
childDiv.className = 'log-child'; childDiv.className = "log-child";
childDiv.dataset.childId = child.id; childDiv.dataset.childId = child.id;
if (child.status === 'Running' || child.status === 'Completed') { if (child.status === "Running" || child.status === "Completed") {
childDiv.classList.add('expanded'); childDiv.classList.add("expanded");
} }
const statusClass = child.status.toLowerCase(); const statusClass = child.status.toLowerCase();
const stepCurrent = child.current_step || 0; // Support both direct fields and nested progress object
const stepTotal = child.total_steps || 0; const stepCurrent = child.current_step ?? child.progress?.current ?? 0;
const duration = child.duration_seconds ? this.formatDuration(child.duration_seconds) : ''; const stepTotal = child.total_steps ?? child.progress?.total ?? 0;
const duration = child.duration_seconds
? this.formatDuration(child.duration_seconds)
: "";
childDiv.innerHTML = ` childDiv.innerHTML = `
<div class="log-child-header" onclick="ProgressPanel.toggleChild('${child.id}')"> <div class="log-child-header" onclick="ProgressPanel.toggleChild('${child.id}')">
<span class="child-indent"></span> <span class="child-indent"></span>
<span class="child-name">${this.escapeHtml(child.name)}</span> <span class="child-name">${this.escapeHtml(child.name)}</span>
@ -250,215 +314,240 @@ const ProgressPanel = {
</div> </div>
`; `;
const itemsContainer = childDiv.querySelector('.log-items'); const itemsContainer = childDiv.querySelector(".log-items");
for (const item of child.items || []) { for (const item of child.items || []) {
const itemEl = this.createItemElement(item); const itemEl = this.createItemElement(item);
itemsContainer.appendChild(itemEl); itemsContainer.appendChild(itemEl);
} }
return childDiv; return childDiv;
}, },
createItemElement(item) { createItemElement(item) {
const itemDiv = document.createElement('div'); const itemDiv = document.createElement("div");
itemDiv.className = 'log-item'; itemDiv.className = "log-item";
itemDiv.dataset.itemId = item.id; itemDiv.dataset.itemId = item.id;
const statusClass = item.status.toLowerCase(); const statusClass = item.status.toLowerCase();
const duration = item.duration_seconds ? `Duration: ${this.formatDuration(item.duration_seconds)}` : ''; const duration = item.duration_seconds
const checkIcon = item.status === 'Completed' ? '✓' : (item.status === 'Running' ? '◎' : '○'); ? `Duration: ${this.formatDuration(item.duration_seconds)}`
: "";
const checkIcon =
item.status === "Completed" ? "✓" : item.status === "Running" ? "◎" : "○";
itemDiv.innerHTML = ` itemDiv.innerHTML = `
<span class="item-dot ${statusClass}"></span> <span class="item-dot ${statusClass}"></span>
<span class="item-name">${this.escapeHtml(item.name)}${item.details ? ` - ${this.escapeHtml(item.details)}` : ''}</span> <span class="item-name">${this.escapeHtml(item.name)}${item.details ? ` - ${this.escapeHtml(item.details)}` : ""}</span>
<div class="item-info"> <div class="item-info">
<span class="item-duration">${duration}</span> <span class="item-duration">${duration}</span>
<span class="item-check ${statusClass}">${checkIcon}</span> <span class="item-check ${statusClass}">${checkIcon}</span>
</div> </div>
`; `;
return itemDiv; return itemDiv;
}, },
renderTerminal() { renderTerminal() {
if (!this.manifest || !this.manifest.terminal_output) return; // Support both formats: terminal_output (direct) and terminal.lines (web JSON)
const terminalLines =
this.manifest?.terminal_output || this.manifest?.terminal?.lines || [];
const container = document.getElementById('terminal-content'); if (!terminalLines.length) return;
if (!container) return;
container.innerHTML = ''; const container = document.getElementById("terminal-content");
if (!container) return;
for (const line of this.manifest.terminal_output.slice(-50)) { container.innerHTML = "";
this.appendTerminalLine(container, line.content, line.line_type || 'info');
}
container.scrollTop = container.scrollHeight; for (const line of terminalLines.slice(-50)) {
}, this.appendTerminalLine(
container,
addTerminalLine(content, lineType) { line.content,
const container = document.getElementById('terminal-content'); line.type || line.line_type || "info",
if (!container) return; );
this.appendTerminalLine(container, content, lineType);
container.scrollTop = container.scrollHeight;
this.incrementProcessedCount();
},
appendTerminalLine(container, content, lineType) {
const lineDiv = document.createElement('div');
lineDiv.className = `terminal-line ${lineType || 'info'}`;
lineDiv.textContent = content;
container.appendChild(lineDiv);
},
incrementProcessedCount() {
const processedEl = document.getElementById('terminal-processed');
if (processedEl) {
const current = parseInt(processedEl.textContent, 10) || 0;
processedEl.textContent = current + 1;
}
},
updateStats(stats) {
const processedEl = document.getElementById('terminal-processed');
if (processedEl && stats.data_points_processed !== undefined) {
processedEl.textContent = stats.data_points_processed;
}
const speedEl = document.getElementById('terminal-speed');
if (speedEl && stats.sources_per_min !== undefined) {
speedEl.textContent = `~${stats.sources_per_min.toFixed(1)} sources/min`;
}
const etaEl = document.getElementById('terminal-eta');
if (etaEl && stats.estimated_remaining_seconds !== undefined) {
etaEl.textContent = this.formatDuration(stats.estimated_remaining_seconds);
}
},
updateSection(sectionId, status, progress) {
const sectionEl = document.querySelector(`[data-section-id="${sectionId}"]`);
if (!sectionEl) return;
const indicator = sectionEl.querySelector('.section-indicator');
const statusBadge = sectionEl.querySelector('.section-status-badge');
const stepBadge = sectionEl.querySelector('.section-step-badge');
if (indicator) {
indicator.className = `section-indicator ${status.toLowerCase()}`;
}
if (statusBadge) {
statusBadge.className = `section-status-badge ${status.toLowerCase()}`;
statusBadge.textContent = status;
}
if (stepBadge && progress) {
stepBadge.textContent = `Step ${progress.current}/${progress.total}`;
}
if (status === 'Running' || status === 'Completed') {
sectionEl.classList.add('expanded');
}
},
updateItem(sectionId, itemId, status, duration) {
const itemEl = document.querySelector(`[data-item-id="${itemId}"]`);
if (!itemEl) return;
const dot = itemEl.querySelector('.item-dot');
const check = itemEl.querySelector('.item-check');
const durationEl = itemEl.querySelector('.item-duration');
const statusClass = status.toLowerCase();
if (dot) {
dot.className = `item-dot ${statusClass}`;
}
if (check) {
check.className = `item-check ${statusClass}`;
check.textContent = status === 'Completed' ? '✓' : (status === 'Running' ? '◎' : '○');
}
if (durationEl && duration) {
durationEl.textContent = `Duration: ${this.formatDuration(duration)}`;
}
},
toggleSection(sectionId) {
const sectionEl = document.querySelector(`[data-section-id="${sectionId}"]`);
if (sectionEl) {
sectionEl.classList.toggle('expanded');
}
},
toggleChild(childId) {
const childEl = document.querySelector(`[data-child-id="${childId}"]`);
if (childEl) {
childEl.classList.toggle('expanded');
}
},
viewDetails(sectionId) {
console.log('View details for section:', sectionId);
},
viewChildDetails(childId) {
console.log('View details for child:', childId);
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
loadManifest(taskId) {
fetch(`/api/autotask/${taskId}/manifest`)
.then(response => response.json())
.then(data => {
if (data.success && data.manifest) {
this.manifest = data.manifest;
this.render();
}
})
.catch(error => {
console.error('Failed to load manifest:', error);
});
},
destroy() {
this.stopRuntimeCounter();
if (this.wsConnection) {
this.wsConnection.close();
this.wsConnection = null;
}
} }
container.scrollTop = container.scrollHeight;
},
addTerminalLine(content, lineType) {
const container = document.getElementById("terminal-content");
if (!container) return;
this.appendTerminalLine(container, content, lineType);
container.scrollTop = container.scrollHeight;
this.incrementProcessedCount();
},
appendTerminalLine(container, content, lineType) {
const lineDiv = document.createElement("div");
lineDiv.className = `terminal-line ${lineType || "info"}`;
lineDiv.textContent = content;
container.appendChild(lineDiv);
},
incrementProcessedCount() {
const processedEl = document.getElementById("terminal-processed");
if (processedEl) {
const current = parseInt(processedEl.textContent, 10) || 0;
processedEl.textContent = current + 1;
}
},
updateStats(stats) {
const processedEl = document.getElementById("terminal-processed");
if (processedEl && stats.data_points_processed !== undefined) {
processedEl.textContent = stats.data_points_processed;
}
const speedEl = document.getElementById("terminal-speed");
if (speedEl && stats.sources_per_min !== undefined) {
speedEl.textContent = `~${stats.sources_per_min.toFixed(1)} sources/min`;
}
const etaEl = document.getElementById("terminal-eta");
if (etaEl && stats.estimated_remaining_seconds !== undefined) {
etaEl.textContent = this.formatDuration(
stats.estimated_remaining_seconds,
);
}
},
updateSection(sectionId, status, progress) {
const sectionEl = document.querySelector(
`[data-section-id="${sectionId}"]`,
);
if (!sectionEl) return;
const indicator = sectionEl.querySelector(".section-indicator");
const statusBadge = sectionEl.querySelector(".section-status-badge");
const stepBadge = sectionEl.querySelector(".section-step-badge");
if (indicator) {
indicator.className = `section-indicator ${status.toLowerCase()}`;
}
if (statusBadge) {
statusBadge.className = `section-status-badge ${status.toLowerCase()}`;
statusBadge.textContent = status;
}
if (stepBadge && progress) {
stepBadge.textContent = `Step ${progress.current}/${progress.total}`;
}
if (status === "Running" || status === "Completed") {
sectionEl.classList.add("expanded");
}
},
updateItem(sectionId, itemId, status, duration) {
const itemEl = document.querySelector(`[data-item-id="${itemId}"]`);
if (!itemEl) return;
const dot = itemEl.querySelector(".item-dot");
const check = itemEl.querySelector(".item-check");
const durationEl = itemEl.querySelector(".item-duration");
const statusClass = status.toLowerCase();
if (dot) {
dot.className = `item-dot ${statusClass}`;
}
if (check) {
check.className = `item-check ${statusClass}`;
check.textContent =
status === "Completed" ? "✓" : status === "Running" ? "◎" : "○";
}
if (durationEl && duration) {
durationEl.textContent = `Duration: ${this.formatDuration(duration)}`;
}
},
toggleSection(sectionId) {
const sectionEl = document.querySelector(
`[data-section-id="${sectionId}"]`,
);
if (sectionEl) {
sectionEl.classList.toggle("expanded");
}
},
toggleChild(childId) {
const childEl = document.querySelector(`[data-child-id="${childId}"]`);
if (childEl) {
childEl.classList.toggle("expanded");
}
},
viewDetails(sectionId) {
console.log("View details for section:", sectionId);
},
viewChildDetails(childId) {
console.log("View details for child:", childId);
},
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
},
loadManifest(taskId) {
fetch(`/api/autotask/${taskId}/manifest`)
.then((response) => response.json())
.then((data) => {
if (data.success && data.manifest) {
this.manifest = data.manifest;
this.render();
}
})
.catch((error) => {
console.error("Failed to load manifest:", error);
});
},
destroy() {
this.stopRuntimeCounter();
// Unregister from singleton instead of closing our own connection
if (
this._boundHandler &&
typeof unregisterTaskProgressHandler === "function"
) {
unregisterTaskProgressHandler(this._boundHandler);
this._boundHandler = null;
console.log("[ProgressPanel] Unregistered from singleton WebSocket");
}
// Don't close the singleton connection - other components may be using it
this.wsConnection = null;
},
}; };
function toggleLogSection(header) { function toggleLogSection(header) {
const section = header.closest('.log-section'); const section = header.closest(".log-section");
if (section) { if (section) {
section.classList.toggle('expanded'); section.classList.toggle("expanded");
} }
} }
function toggleLogChild(header) { function toggleLogChild(header) {
const child = header.closest('.log-child'); const child = header.closest(".log-child");
if (child) { if (child) {
child.classList.toggle('expanded'); child.classList.toggle("expanded");
} }
} }
function viewSectionDetails(sectionId) { function viewSectionDetails(sectionId) {
ProgressPanel.viewDetails(sectionId); ProgressPanel.viewDetails(sectionId);
} }
function viewChildDetails(childId) { function viewChildDetails(childId) {
ProgressPanel.viewChildDetails(childId); ProgressPanel.viewChildDetails(childId);
} }
window.ProgressPanel = ProgressPanel; window.ProgressPanel = ProgressPanel;

View file

@ -8,6 +8,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
max-height: 100vh;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
@ -17,6 +18,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
max-height: 100%;
min-height: 0; min-height: 0;
background: var(--bg, #0a0a0a); background: var(--bg, #0a0a0a);
color: var(--text-secondary, #e0e0e0); color: var(--text-secondary, #e0e0e0);
@ -95,6 +97,31 @@
/* Section Container */ /* Section Container */
.taskmd-section { .taskmd-section {
border-bottom: 1px solid var(--border, #1a1a1a); border-bottom: 1px solid var(--border, #1a1a1a);
flex-shrink: 0;
}
/* Status section - compact */
.taskmd-section-status {
flex-shrink: 0;
}
/* Progress log section - scrollable */
.taskmd-section-progress {
flex: 0 1 auto;
min-height: 100px;
max-height: 40%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Terminal section - takes remaining space */
.taskmd-section-terminal {
flex: 1 1 auto;
min-height: 150px;
display: flex;
flex-direction: column;
overflow: hidden;
} }
.taskmd-section-header { .taskmd-section-header {
@ -206,9 +233,9 @@
/* PROGRESS LOG Section */ /* PROGRESS LOG Section */
.taskmd-progress-content { .taskmd-progress-content {
background: var(--bg, #0a0a0a); background: var(--bg, #0a0a0a);
flex: 1; flex: 1 1 auto;
min-height: 0; min-height: 0;
max-height: 350px; max-height: 100%;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
@ -516,11 +543,10 @@
/* TERMINAL Section */ /* TERMINAL Section */
.taskmd-terminal { .taskmd-terminal {
flex-shrink: 0; flex: 1 1 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 120px; min-height: 120px;
max-height: 200px;
overflow: hidden; overflow: hidden;
} }
@ -575,7 +601,7 @@
} }
.taskmd-terminal-output { .taskmd-terminal-output {
flex: 1; flex: 1 1 auto;
padding: 16px 24px; padding: 16px 24px;
background: var(--bg, #0a0a0a); background: var(--bg, #0a0a0a);
font-family: "JetBrains Mono", "Fira Code", monospace; font-family: "JetBrains Mono", "Fira Code", monospace;
@ -583,9 +609,118 @@
line-height: 1.7; line-height: 1.7;
color: var(--text-secondary, #888); color: var(--text-secondary, #888);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
min-height: 0; min-height: 0;
} }
/* Markdown content in terminal */
.taskmd-terminal-output .markdown-content {
font-family: var(
--sentient-font-family,
"Inter",
-apple-system,
sans-serif
);
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary, #ccc);
}
.taskmd-terminal-output .markdown-content h1,
.taskmd-terminal-output .markdown-content h2,
.taskmd-terminal-output .markdown-content h3 {
color: var(--text, #fff);
font-weight: 600;
margin: 16px 0 8px 0;
}
.taskmd-terminal-output .markdown-content h1 {
font-size: 18px;
}
.taskmd-terminal-output .markdown-content h2 {
font-size: 16px;
}
.taskmd-terminal-output .markdown-content h3 {
font-size: 14px;
}
.taskmd-terminal-output .markdown-content p {
margin: 8px 0;
}
.taskmd-terminal-output .markdown-content ul,
.taskmd-terminal-output .markdown-content ol {
margin: 8px 0;
padding-left: 24px;
}
.taskmd-terminal-output .markdown-content li {
margin: 4px 0;
}
.taskmd-terminal-output .markdown-content pre {
background: #151515;
border: 1px solid #252525;
border-radius: 6px;
padding: 12px 16px;
margin: 12px 0;
overflow-x: auto;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 12px;
line-height: 1.5;
}
.taskmd-terminal-output .markdown-content code {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 4px;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 12px;
color: var(--accent, #c5f82a);
}
.taskmd-terminal-output .markdown-content pre code {
background: transparent;
padding: 0;
color: #aaa;
}
.taskmd-terminal-output .markdown-content blockquote {
border-left: 3px solid var(--accent, #c5f82a);
padding-left: 16px;
margin: 12px 0;
color: var(--text-muted, #888);
font-style: italic;
}
.taskmd-terminal-output .markdown-content table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
.taskmd-terminal-output .markdown-content th,
.taskmd-terminal-output .markdown-content td {
border: 1px solid #252525;
padding: 8px 12px;
text-align: left;
}
.taskmd-terminal-output .markdown-content th {
background: #151515;
font-weight: 600;
color: var(--text, #fff);
}
.taskmd-terminal-output .markdown-content a {
color: var(--accent, #c5f82a);
text-decoration: none;
}
.taskmd-terminal-output .markdown-content a:hover {
text-decoration: underline;
}
.taskmd-terminal-output .terminal-line { .taskmd-terminal-output .terminal-line {
padding: 3px 0; padding: 3px 0;
white-space: pre-wrap; white-space: pre-wrap;
@ -619,12 +754,14 @@
.taskmd-terminal-output .terminal-code { .taskmd-terminal-output .terminal-code {
background: #151515; background: #151515;
border: 1px solid #252525; border: 1px solid #252525;
border-radius: 4px; border-radius: 6px;
padding: 8px 12px; padding: 12px 16px;
margin: 6px 0; margin: 8px 0;
font-size: 12px; font-size: 12px;
color: #aaa; color: #aaa;
overflow-x: auto; overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
} }
/* Checkmark items */ /* Checkmark items */
@ -660,14 +797,110 @@
margin-right: 8px; margin-right: 8px;
} }
/* Terminal markdown elements */
.taskmd-terminal-output .terminal-h1 {
font-size: 18px;
font-weight: 600;
color: var(--text, #fff);
margin: 16px 0 8px 0;
font-family: var(--sentient-font-family, "Inter", sans-serif);
}
.taskmd-terminal-output .terminal-h2 {
font-size: 16px;
font-weight: 600;
color: var(--text, #fff);
margin: 14px 0 6px 0;
font-family: var(--sentient-font-family, "Inter", sans-serif);
}
.taskmd-terminal-output .terminal-h3 {
font-size: 14px;
font-weight: 600;
color: var(--text, #fff);
margin: 12px 0 4px 0;
font-family: var(--sentient-font-family, "Inter", sans-serif);
}
.taskmd-terminal-output .terminal-p {
margin: 8px 0;
line-height: 1.6;
}
.taskmd-terminal-output .terminal-ul,
.taskmd-terminal-output .terminal-ol {
margin: 8px 0;
padding-left: 24px;
list-style: none;
}
.taskmd-terminal-output .terminal-li {
margin: 4px 0;
position: relative;
}
.taskmd-terminal-output .terminal-li::before {
content: "•";
position: absolute;
left: -16px;
color: var(--accent, #c5f82a);
}
.taskmd-terminal-output .terminal-oli {
margin: 4px 0;
list-style: decimal;
}
.taskmd-terminal-output .terminal-inline-code {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 4px;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 12px;
color: var(--accent, #c5f82a);
}
.taskmd-terminal-output .terminal-quote {
border-left: 3px solid var(--accent, #c5f82a);
padding-left: 16px;
margin: 12px 0;
color: var(--text-muted, #888);
font-style: italic;
}
.taskmd-terminal-output .terminal-hr {
border: none;
border-top: 1px solid var(--border, #252525);
margin: 16px 0;
}
.taskmd-terminal-output .check-mark {
color: var(--success, #22c55e);
font-weight: 600;
}
.taskmd-terminal-output .check-empty {
color: var(--text-muted, #555);
}
.taskmd-terminal-output a {
color: var(--accent, #c5f82a);
text-decoration: none;
}
.taskmd-terminal-output a:hover {
text-decoration: underline;
}
/* Actions */ /* Actions */
.taskmd-actions { .taskmd-actions {
flex-shrink: 0;
display: flex; display: flex;
justify-content: flex-end;
gap: 12px; gap: 12px;
padding: 16px 24px; padding: 20px 24px;
border-top: 1px solid var(--border, #1a1a1a); border-top: 1px solid var(--border, #1a1a1a);
background: var(--bg-secondary, #0d0d0d); background: var(--bg-secondary, #0d0d0d);
flex-shrink: 0;
} }
.taskmd-actions .btn-action-rich { .taskmd-actions .btn-action-rich {

View file

@ -124,8 +124,8 @@
class="tasks-list-scroll" class="tasks-list-scroll"
id="task-list" id="task-list"
hx-get="/api/tasks?filter=all" hx-get="/api/tasks?filter=all"
hx-trigger="load, taskCreated from:body" hx-trigger="load, taskCreated from:body throttle:2s"
hx-swap="innerHTML" hx-swap="innerHTML transition:false"
> >
<!-- Loading state - replaced by HTMX --> <!-- Loading state - replaced by HTMX -->
<div class="loading-state"> <div class="loading-state">
@ -334,19 +334,12 @@
"quick-intent-input", "quick-intent-input",
).value = ""; ).value = "";
// Trigger task list refresh // Select the task and let tasks.js handle polling
htmx.trigger(document.body, "taskCreated"); selectTask(response.task_id);
// Hide success message after task is selected
// After a short delay to let the list reload, select the new task
setTimeout(function () { setTimeout(function () {
selectTask(response.task_id); intentResult.style.display = "none";
// Start polling for updates }, 2000);
startTaskPolling(response.task_id);
// Hide success message after task is selected
setTimeout(function () {
intentResult.style.display = "none";
}, 2000);
}, 500);
} else { } else {
intentResult.innerHTML = `<span class="intent-error">✗ ${response.message || "Failed to create task"}</span>`; intentResult.innerHTML = `<span class="intent-error">✗ ${response.message || "Failed to create task"}</span>`;
intentResult.style.display = "block"; intentResult.style.display = "block";
@ -369,55 +362,6 @@
}); });
}); });
// Poll for task status updates
let taskPollingInterval = null;
function startTaskPolling(taskId) {
// Clear any existing polling
if (taskPollingInterval) {
clearInterval(taskPollingInterval);
}
taskPollingInterval = setInterval(function () {
fetch(`/api/tasks/${taskId}`, {
headers: { Accept: "application/json" },
})
.then((r) => r.json())
.then((task) => {
console.log("[TASK] Poll status:", task.status);
// Refresh the detail panel
htmx.ajax("GET", `/api/tasks/${taskId}`, {
target: "#task-detail-content",
swap: "innerHTML",
});
// Refresh task list to update status badges
htmx.trigger(document.body, "taskCreated");
// Stop polling if task is complete or failed
if (
task.status === "completed" ||
task.status === "failed" ||
task.status === "cancelled"
) {
clearInterval(taskPollingInterval);
taskPollingInterval = null;
loadTaskStats();
}
})
.catch((e) => {
console.warn("Failed to poll task:", e);
});
}, 2000); // Poll every 2 seconds
}
function stopTaskPolling() {
if (taskPollingInterval) {
clearInterval(taskPollingInterval);
taskPollingInterval = null;
}
}
// Load task statistics // Load task statistics
function loadTaskStats() { function loadTaskStats() {
fetch("/api/tasks/stats/json") fetch("/api/tasks/stats/json")

File diff suppressed because it is too large Load diff