botui/ui/suite/tasks/tasks.js
Rodrigo Rodriguez (Pragmatismo) 4499bcda7a Fix flicker: incremental DOM updates only, smooth CSS transitions
- updateProgressTreeInPlace: only updates changed text/classes, no innerHTML rebuild
- updateChildInPlace, updateItemsInPlace: granular element updates
- Check if value changed before updating (prevents unnecessary DOM writes)
- CSS transitions on all tree elements (0.2s ease-out)
- Status dot now yellow/pulsing by default (was missing)
- Running step badges get accent color
- dot-pulse animation with scale effect
2026-01-01 10:49:30 -03:00

2225 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =============================================================================
TASKS APP JAVASCRIPT
Automated Intelligent Task Management Interface
============================================================================= */
// =============================================================================
// STATE MANAGEMENT
// =============================================================================
// Prevent duplicate declaration when script is reloaded via HTMX
if (typeof TasksState === "undefined") {
var TasksState = {
selectedTaskId: null, // No task selected initially
currentFilter: "complete",
tasks: [],
wsConnection: null,
agentLogPaused: false,
selectedItemType: "task", // task, goal, pending, scheduler, monitor
};
}
// =============================================================================
// INITIALIZATION
// =============================================================================
document.addEventListener("DOMContentLoaded", function () {
// Only init if tasks app is visible
if (document.querySelector(".tasks-app")) {
initTasksApp();
}
});
// Reinitialize when tasks page is loaded via HTMX
document.body.addEventListener("htmx:afterSwap", function (evt) {
// Check if tasks app was just loaded
if (evt.detail.target && evt.detail.target.id === "main-content") {
if (document.querySelector(".tasks-app")) {
console.log(
"[Tasks] Detected tasks app loaded via HTMX, initializing...",
);
initTasksApp();
}
}
});
function initTasksApp() {
// Only init WebSocket if not already connected
if (
!TasksState.wsConnection ||
TasksState.wsConnection.readyState !== WebSocket.OPEN
) {
initWebSocket();
} else {
console.log("[Tasks] WebSocket already connected, skipping init");
}
setupEventListeners();
setupKeyboardShortcuts();
setupIntentInputHandlers();
setupHtmxListeners();
scrollAgentLogToBottom();
console.log("[Tasks] Initialized");
}
function setupHtmxListeners() {
// Listen for HTMX content swaps to apply pending manifest updates
document.body.addEventListener("htmx:afterSwap", function (evt) {
const target = evt.detail.target;
if (
target &&
(target.id === "task-detail-content" ||
target.closest("#task-detail-content"))
) {
console.log(
"[HTMX] Task detail content loaded, checking for pending manifest updates",
);
// Check if there's a pending manifest update for the selected task
if (
TasksState.selectedTaskId &&
pendingManifestUpdates.has(TasksState.selectedTaskId)
) {
const manifest = pendingManifestUpdates.get(TasksState.selectedTaskId);
console.log(
"[HTMX] Applying pending manifest for task:",
TasksState.selectedTaskId,
);
setTimeout(() => {
renderManifestProgress(TasksState.selectedTaskId, manifest, 0);
}, 50);
}
}
});
}
function setupIntentInputHandlers() {
const input = document.getElementById("quick-intent-input");
const btn = document.getElementById("quick-intent-btn");
if (input && btn) {
input.addEventListener("keypress", function (e) {
if (e.key === "Enter" && input.value.trim()) {
e.preventDefault();
htmx.trigger(btn, "click");
}
});
}
document.body.addEventListener("htmx:beforeRequest", function (e) {
if (e.detail.elt.id === "quick-intent-btn") {
const resultDiv = document.getElementById("intent-result");
resultDiv.innerHTML = `
<div class="result-card">
<div class="result-message">Processing your request...</div>
<div class="result-progress">
<div class="result-progress-bar" style="width: 30%"></div>
</div>
</div>
`;
}
});
document.body.addEventListener("htmx:afterRequest", function (e) {
if (e.detail.elt.id === "quick-intent-btn") {
const resultDiv = document.getElementById("intent-result");
try {
const response = JSON.parse(e.detail.xhr.responseText);
// Handle async task creation (status 202 Accepted)
if (response.status === "running" && response.task_id) {
// Clear input immediately
document.getElementById("quick-intent-input").value = "";
// Select the task to show progress in detail panel
setTimeout(() => {
selectTask(response.task_id);
}, 500);
// Clear result div - progress is shown in floating panel
resultDiv.innerHTML = "";
// Trigger task list refresh to show new task
htmx.trigger(document.body, "taskCreated");
// Start polling for task status
startTaskPolling(response.task_id);
return;
}
// Handle completed task (legacy sync response)
if (response.success) {
let html = `<div class="result-card">
<div class="result-message result-success">✓ ${response.message || "Done!"}</div>`;
if (response.app_url) {
html += `<a href="${response.app_url}" class="result-link" target="_blank">
Open App →
</a>`;
}
if (response.task_id) {
html += `<div style="margin-top:8px;color:#666;font-size:13px;">Task ID: ${response.task_id}</div>`;
}
html += `</div>`;
resultDiv.innerHTML = html;
document.getElementById("quick-intent-input").value = "";
htmx.trigger(document.body, "taskCreated");
} else {
resultDiv.innerHTML = `<div class="result-card">
<div class="result-message result-error">✗ ${response.error || response.message || "Something went wrong"}</div>
</div>`;
}
} catch (err) {
resultDiv.innerHTML = `<div class="result-card">
<div class="result-message result-error">✗ Failed to process response</div>
</div>`;
}
}
});
// Save intent text before submit for progress display
if (input) {
input.addEventListener("input", function () {
input.setAttribute("data-last-intent", input.value);
});
}
}
// Task polling for async task creation
let activePollingTaskId = null;
let pollingInterval = null;
function startTaskPolling(taskId) {
// Stop any existing polling
stopTaskPolling();
activePollingTaskId = taskId;
let pollCount = 0;
const maxPolls = 180; // 3 minutes at 1 second intervals
console.log(`[POLL] Starting polling for task ${taskId}`);
pollingInterval = setInterval(async () => {
pollCount++;
if (pollCount > maxPolls) {
console.log(`[POLL] Max polls reached for task ${taskId}`);
stopTaskPolling();
errorFloatingProgress("Task timed out");
return;
}
try {
const response = await fetch(`/api/tasks/${taskId}`, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
console.error(`[POLL] Failed to fetch task status: ${response.status}`);
return;
}
const task = await response.json();
console.log(
`[POLL] Task ${taskId} status: ${task.status}, progress: ${task.progress || 0}%`,
);
// Update progress
const progress = task.progress || 0;
const message = task.current_step || task.status || "Processing...";
updateFloatingProgressBar(message, progress, task);
// Check if task is complete
if (task.status === "completed" || task.status === "complete") {
stopTaskPolling();
completeFloatingProgress(task);
htmx.trigger(document.body, "taskCreated"); // Refresh task list
showToast("Task completed successfully!", "success");
} else if (task.status === "failed" || task.status === "error") {
stopTaskPolling();
errorFloatingProgress(task.error || "Task failed");
htmx.trigger(document.body, "taskCreated"); // Refresh task list
showToast(task.error || "Task failed", "error");
}
} catch (err) {
console.error(`[POLL] Error polling task ${taskId}:`, err);
}
}, 1000); // Poll every 1 second
}
function stopTaskPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
activePollingTaskId = null;
}
// =============================================================================
// WEBSOCKET CONNECTION
// =============================================================================
function initWebSocket() {
// Don't create new connection if one already exists and is open/connecting
if (TasksState.wsConnection) {
const state = TasksState.wsConnection.readyState;
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) {
console.log(
"[Tasks WS] WebSocket already connected/connecting, skipping",
);
return;
}
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws/task-progress`;
console.log("[Tasks WS] Attempting connection to:", wsUrl);
try {
TasksState.wsConnection = new WebSocket(wsUrl);
TasksState.wsConnection.onopen = function () {
console.log("[Tasks WS] WebSocket connected successfully");
addAgentLog("info", "[SYSTEM] Connected to task orchestrator");
};
TasksState.wsConnection.onmessage = function (event) {
console.log("[Tasks WS] Raw message received:", event.data);
try {
const data = JSON.parse(event.data);
console.log("[Tasks WS] Parsed message:", data.type, data);
handleWebSocketMessage(data);
} catch (e) {
console.error("[Tasks WS] Failed to parse message:", e, event.data);
}
};
TasksState.wsConnection.onclose = function (event) {
console.log(
"[Tasks WS] WebSocket disconnected, code:",
event.code,
"reason:",
event.reason,
);
setTimeout(initWebSocket, 5000);
};
TasksState.wsConnection.onerror = function (error) {
console.error("[Tasks WS] WebSocket error:", error);
};
} catch (e) {
console.error("[Tasks WS] Failed to create WebSocket:", e);
}
}
function handleWebSocketMessage(data) {
console.log("[Tasks WS] handleWebSocketMessage called with type:", data.type);
switch (data.type) {
case "connected":
console.log("[Tasks WS] Connected to task progress stream");
addAgentLog("info", "[SYSTEM] Task progress stream connected");
break;
case "task_started":
console.log("[Tasks WS] TASK_STARTED:", data.message);
addAgentLog("accent", `[TASK] Started: ${data.message}`);
// Update terminal in detail panel
updateDetailTerminal(data.task_id, data.message, "started");
// Refresh task list
if (typeof htmx !== "undefined") {
htmx.trigger(document.body, "taskCreated");
}
// Select the task if not already selected
if (data.task_id) {
selectTask(data.task_id);
}
break;
case "task_progress":
console.log(
"[Tasks WS] TASK_PROGRESS - step:",
data.step,
"message:",
data.message,
);
addAgentLog("info", `[${data.step}] ${data.message}`);
// Update terminal in detail panel with real data
updateDetailTerminal(
data.task_id,
data.message,
data.step,
data.activity,
);
// Update progress bar in detail panel
updateDetailProgress(
data.task_id,
data.current_step,
data.total_steps,
data.progress,
);
break;
case "task_completed":
console.log("[Tasks WS] TASK_COMPLETED:", data.message);
addAgentLog("success", `[COMPLETE] ${data.message}`);
// Extract app_url from details if present
let appUrl = null;
if (data.details && data.details.startsWith("app_url:")) {
appUrl = data.details.substring(8);
addAgentLog("success", `🚀 App URL: ${appUrl}`);
showAppUrlNotification(appUrl);
}
// Update terminal with completion
updateDetailTerminal(
data.task_id,
data.message,
"complete",
data.activity,
);
updateDetailProgress(
data.task_id,
data.total_steps,
data.total_steps,
100,
);
onTaskCompleted(data, appUrl);
// Play completion sound
playCompletionSound();
// Refresh task list and details
if (typeof htmx !== "undefined") {
htmx.trigger(document.body, "taskCreated");
}
if (data.task_id) {
setTimeout(() => loadTaskDetails(data.task_id), 500);
}
break;
case "task_error":
console.log("[Tasks WS] TASK_ERROR:", data.error || data.message);
addAgentLog("error", `[ERROR] ${data.error || data.message}`);
updateDetailTerminal(data.task_id, data.error || data.message, "error");
onTaskFailed(data, data.error);
// Refresh task details to show error
if (data.task_id) {
setTimeout(() => loadTaskDetails(data.task_id), 500);
}
break;
case "task_update":
updateTaskCard(data.task);
if (data.task && data.task.id === TasksState.selectedTaskId) {
updateTaskDetail(data.task);
}
break;
case "step_progress":
updateStepProgress(data.taskId, data.step);
break;
case "agent_log":
addAgentLog(data.level, data.message);
break;
case "decision_required":
showDecisionRequired(data.decision);
break;
case "llm_stream":
// Don't show raw LLM stream in terminal - it contains HTML/code garbage
// Progress is shown via manifest_update events instead
console.log("[Tasks WS] LLM streaming...");
break;
case "manifest_update":
console.log(
"[Tasks WS] MANIFEST_UPDATE for task:",
data.task_id,
"selected:",
TasksState.selectedTaskId,
);
// Update the progress log section with manifest data
if (data.details) {
try {
const manifestData = JSON.parse(data.details);
console.log(
"[Tasks WS] Manifest parsed, sections:",
manifestData.sections?.length,
"status:",
manifestData.status,
);
renderManifestProgress(data.task_id, manifestData);
} catch (e) {
console.error(
"[Tasks WS] Failed to parse manifest:",
e,
data.details?.substring(0, 200),
);
}
} else {
console.warn("[Tasks WS] manifest_update received but no details");
}
break;
}
}
// Store pending manifest updates for tasks whose elements aren't loaded yet
const pendingManifestUpdates = new Map();
function renderManifestProgress(taskId, manifest, retryCount = 0) {
console.log(
"[Manifest] renderManifestProgress called for task:",
taskId,
"selected:",
TasksState.selectedTaskId,
);
// Only update if this is the selected task
if (TasksState.selectedTaskId !== taskId) {
console.log("[Manifest] Skipping - not selected task");
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) {
console.log("[Manifest] No progress log element found, retry:", retryCount);
// If task is selected but element not yet loaded, retry after a delay
if (retryCount < 5) {
pendingManifestUpdates.set(taskId, manifest);
setTimeout(
() => {
const pending = pendingManifestUpdates.get(taskId);
if (pending && TasksState.selectedTaskId === taskId) {
renderManifestProgress(taskId, pending, retryCount + 1);
}
},
150 * (retryCount + 1),
);
}
return;
}
// Clear pending update
pendingManifestUpdates.delete(taskId);
if (!manifest || !manifest.sections) {
console.log("[Manifest] No sections in manifest");
return;
}
const totalSteps = manifest.progress?.total || 60;
// Update STATUS section if exists
updateStatusSection(manifest);
// Check if tree exists - if not, create it; if yes, update incrementally
let tree = progressLog.querySelector(".taskmd-tree");
if (!tree) {
// First render only - create the tree structure
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 what changed (no flicker)
updateProgressTreeInPlace(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 text only if changed
const actionText = statusContent.querySelector(
".status-current .status-text",
);
const currentAction =
manifest.status?.current_action ||
manifest.current_status?.current_action ||
"Processing...";
if (actionText && actionText.textContent !== currentAction) {
actionText.textContent = currentAction;
}
// Update runtime text only
const runtimeEl = statusContent.querySelector(".status-main .status-time");
const runtime =
manifest.status?.runtime_display || manifest.runtime || "Not started";
if (runtimeEl) {
// Only update text content, preserve indicator
const indicator = runtimeEl.querySelector(".status-indicator");
if (!indicator) {
runtimeEl.innerHTML = `Runtime: ${runtime} <span class="status-indicator"></span>`;
} else {
runtimeEl.firstChild.textContent = `Runtime: ${runtime} `;
}
}
// Update estimated text only
const estimatedEl = statusContent.querySelector(
".status-current .status-time",
);
const estimated =
manifest.status?.estimated_display ||
(manifest.estimated_seconds
? `${manifest.estimated_seconds} sec`
: "calculating...");
if (estimatedEl) {
const gear = estimatedEl.querySelector(".status-gear");
if (!gear) {
estimatedEl.innerHTML = `Estimated: ${estimated} <span class="status-gear">⚙</span>`;
} else {
estimatedEl.firstChild.textContent = `Estimated: ${estimated} `;
}
}
}
function buildProgressTreeHTML(manifest, totalSteps) {
let html = '<div class="taskmd-tree">';
for (const section of manifest.sections) {
// Normalize status - backend sends "Running", "Completed", etc.
const rawStatus = section.status || "Pending";
const statusClass = rawStatus.toLowerCase();
const isRunning = statusClass === "running";
const globalCurrent =
section.progress?.global_current || section.progress?.current || 0;
console.log(
"[Manifest] Section:",
section.name,
"status:",
rawStatus,
"children:",
section.children?.length,
);
html += `
<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-step-badge">Step ${globalCurrent}/${totalSteps}</span>
<span class="tree-status ${statusClass}">${rawStatus}</span>
<span class="tree-section-dot ${statusClass}"></span>
</div>
<div class="tree-children">`;
// Children (e.g., "Database Schema Design" under "Database & Models")
if (section.children && section.children.length > 0) {
for (const child of section.children) {
const childRawStatus = child.status || "Pending";
const childStatus = childRawStatus.toLowerCase();
const childIsRunning = childStatus === "running";
console.log(
"[Manifest] Child:",
child.name,
"status:",
childRawStatus,
"items:",
(child.item_groups?.length || 0) + (child.items?.length || 0),
);
html += `
<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-item-dot ${childStatus}"></span>
<span class="tree-name">${escapeHtml(child.name)}</span>
<span class="tree-step-badge">Step ${child.progress?.current || 0}/${child.progress?.total || 1}</span>
<span class="tree-status ${childStatus}">${childRawStatus}</span>
</div>
<div class="tree-items">`;
// Items within child (e.g., "email, password_hash, email_verified")
const childItems = [
...(child.item_groups || []),
...(child.items || []),
];
for (const item of childItems) {
html += buildItemHTML(item);
}
html += `</div></div>`;
}
}
// Section-level items (items directly under section, not in children)
const sectionItems = [
...(section.item_groups || []),
...(section.items || []),
];
for (const item of sectionItems) {
html += buildItemHTML(item);
}
html += `</div></div>`;
}
html += "</div>";
return 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 || "";
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>`;
}
// Incremental update - only change what's different (prevents flicker)
function updateProgressTreeInPlace(tree, manifest, totalSteps) {
for (const section of manifest.sections) {
const sectionEl = tree.querySelector(`[data-section-id="${section.id}"]`);
if (!sectionEl) continue;
const rawStatus = section.status || "Pending";
const statusClass = rawStatus.toLowerCase();
const globalCurrent =
section.progress?.global_current || section.progress?.current || 0;
const isExpanded = sectionEl.classList.contains("expanded");
// Update section classes without removing expanded
const newClasses = `tree-section ${statusClass}${statusClass === "running" || isExpanded ? " expanded" : ""}`;
if (sectionEl.className !== newClasses) {
sectionEl.className = newClasses;
}
// Update step badge text only if changed
const stepBadge = sectionEl.querySelector(
":scope > .tree-row .tree-step-badge",
);
const stepText = `Step ${globalCurrent}/${totalSteps}`;
if (stepBadge && stepBadge.textContent !== stepText) {
stepBadge.textContent = stepText;
}
// Update status text and class only if changed
const statusEl = sectionEl.querySelector(":scope > .tree-row .tree-status");
if (statusEl) {
if (statusEl.textContent !== rawStatus) {
statusEl.textContent = rawStatus;
}
const statusClasses = `tree-status ${statusClass}`;
if (statusEl.className !== statusClasses) {
statusEl.className = statusClasses;
}
}
// Update section dot
const sectionDot = sectionEl.querySelector(
":scope > .tree-row .tree-section-dot",
);
if (sectionDot) {
const dotClasses = `tree-section-dot ${statusClass}`;
if (sectionDot.className !== dotClasses) {
sectionDot.className = dotClasses;
}
}
// Update children
if (section.children) {
for (const child of section.children) {
updateChildInPlace(sectionEl, child);
}
}
// Update section-level items
const childrenContainer = sectionEl.querySelector(".tree-children");
if (childrenContainer) {
updateItemsInPlace(childrenContainer, [
...(section.item_groups || []),
...(section.items || []),
]);
}
}
}
function updateChildInPlace(sectionEl, child) {
const childEl = sectionEl.querySelector(`[data-child-id="${child.id}"]`);
if (!childEl) return;
const rawStatus = child.status || "Pending";
const statusClass = rawStatus.toLowerCase();
const isExpanded = childEl.classList.contains("expanded");
// Update child classes
const newClasses = `tree-child ${statusClass}${statusClass === "running" || isExpanded ? " expanded" : ""}`;
if (childEl.className !== newClasses) {
childEl.className = newClasses;
}
// Update step badge
const stepBadge = childEl.querySelector(
":scope > .tree-row .tree-step-badge",
);
const stepText = `Step ${child.progress?.current || 0}/${child.progress?.total || 1}`;
if (stepBadge && stepBadge.textContent !== stepText) {
stepBadge.textContent = stepText;
}
// Update status
const statusEl = childEl.querySelector(":scope > .tree-row .tree-status");
if (statusEl) {
if (statusEl.textContent !== rawStatus) {
statusEl.textContent = rawStatus;
}
const statusClasses = `tree-status ${statusClass}`;
if (statusEl.className !== statusClasses) {
statusEl.className = statusClasses;
}
}
// Update child dot
const childDot = childEl.querySelector(":scope > .tree-row .tree-item-dot");
if (childDot) {
const dotClasses = `tree-item-dot ${statusClass}`;
if (childDot.className !== dotClasses) {
childDot.className = dotClasses;
}
}
// Update items within child
const itemsContainer = childEl.querySelector(".tree-items");
if (itemsContainer) {
updateItemsInPlace(itemsContainer, [
...(child.item_groups || []),
...(child.items || []),
]);
}
}
function updateItemsInPlace(container, items) {
if (!container || !items) return;
for (const item of items) {
const itemId = item.id || item.name || item.display_name;
let itemEl = container.querySelector(`[data-item-id="${itemId}"]`);
if (!itemEl) {
// New item - append it
container.insertAdjacentHTML("beforeend", buildItemHTML(item));
continue;
}
const rawStatus = item.status || "Pending";
const status = rawStatus.toLowerCase();
// Update item class
const newClasses = `tree-item ${status}`;
if (itemEl.className !== newClasses) {
itemEl.className = newClasses;
}
// Update dot
const dot = itemEl.querySelector(".tree-item-dot");
if (dot) {
const dotClasses = `tree-item-dot ${status}`;
if (dot.className !== dotClasses) {
dot.className = dotClasses;
}
}
// Update check
const check = itemEl.querySelector(".tree-item-check");
if (check) {
const checkClasses = `tree-item-check ${status}`;
if (check.className !== checkClasses) {
check.className = checkClasses;
}
const checkText = status === "completed" ? "✓" : "";
if (check.textContent !== checkText) {
check.textContent = checkText;
}
}
// Update duration
const durationEl = itemEl.querySelector(".tree-item-duration");
if (durationEl && item.duration_seconds) {
const durationText =
item.duration_seconds >= 60
? `Duration: ${Math.floor(item.duration_seconds / 60)} min`
: `Duration: ${item.duration_seconds} sec`;
if (durationEl.textContent !== durationText) {
durationEl.textContent = durationText;
}
}
}
}
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) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function updateActivityMetrics(activity) {
if (!activity) return;
const metricsEl = document.getElementById("floating-activity-metrics");
if (!metricsEl) return;
let html = "";
if (activity.phase) {
html += `<div class="metric-row"><span class="metric-label">Phase:</span> <span class="metric-value phase-${activity.phase}">${activity.phase.toUpperCase()}</span></div>`;
}
if (activity.items_processed !== undefined) {
const total = activity.items_total ? `/${activity.items_total}` : "";
html += `<div class="metric-row"><span class="metric-label">Processed:</span> <span class="metric-value">${activity.items_processed}${total} items</span></div>`;
}
if (activity.speed_per_min) {
html += `<div class="metric-row"><span class="metric-label">Speed:</span> <span class="metric-value">~${activity.speed_per_min.toFixed(1)} items/min</span></div>`;
}
if (activity.eta_seconds) {
const mins = Math.floor(activity.eta_seconds / 60);
const secs = activity.eta_seconds % 60;
const eta = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
html += `<div class="metric-row"><span class="metric-label">ETA:</span> <span class="metric-value">${eta}</span></div>`;
}
if (activity.bytes_processed) {
const kb = (activity.bytes_processed / 1024).toFixed(1);
html += `<div class="metric-row"><span class="metric-label">Generated:</span> <span class="metric-value">${kb} KB</span></div>`;
}
if (activity.tokens_used) {
html += `<div class="metric-row"><span class="metric-label">Tokens:</span> <span class="metric-value">${activity.tokens_used.toLocaleString()}</span></div>`;
}
if (activity.files_created && activity.files_created.length > 0) {
html += `<div class="metric-row"><span class="metric-label">Files:</span> <span class="metric-value">${activity.files_created.length} created</span></div>`;
}
if (activity.tables_created && activity.tables_created.length > 0) {
html += `<div class="metric-row"><span class="metric-label">Tables:</span> <span class="metric-value">${activity.tables_created.length} synced</span></div>`;
}
if (activity.current_item) {
html += `<div class="metric-row current-item"><span class="metric-label">Current:</span> <span class="metric-value">${activity.current_item}</span></div>`;
}
metricsEl.innerHTML = html;
}
function logFinalStats(activity) {
if (!activity) return;
addAgentLog("info", "─────────────────────────────────");
addAgentLog("info", "GENERATION COMPLETE");
if (activity.files_created && activity.files_created.length > 0) {
addAgentLog("success", `Files created: ${activity.files_created.length}`);
activity.files_created.forEach((f) => addAgentLog("info", `${f}`));
}
if (activity.tables_created && activity.tables_created.length > 0) {
addAgentLog("success", `Tables synced: ${activity.tables_created.length}`);
activity.tables_created.forEach((t) => addAgentLog("info", `${t}`));
}
if (activity.bytes_processed) {
const kb = (activity.bytes_processed / 1024).toFixed(1);
addAgentLog("info", `Total size: ${kb} KB`);
}
addAgentLog("info", "─────────────────────────────────");
}
// =============================================================================
// FLOATING PROGRESS PANEL
// =============================================================================
// Update terminal in the detail panel with real-time data
function updateDetailTerminal(taskId, message, step, activity) {
const terminalOutput = document.getElementById(`terminal-output-${taskId}`);
if (!terminalOutput) {
// Try generic terminal output
const genericTerminal = document.querySelector(".terminal-output-rich");
if (genericTerminal) {
addTerminalLine(genericTerminal, message, step);
}
return;
}
addTerminalLine(terminalOutput, message, step, activity);
}
// Format markdown-like text for terminal display
function formatTerminalMarkdown(text) {
if (!text) return "";
// Headers (## Header)
text = text.replace(
/^##\s+(.+)$/gm,
'<strong class="terminal-header">$1</strong>',
);
// Bold (**text**)
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
// Inline code (`code`)
text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
// Code blocks (```code```)
text = text.replace(
/```([\s\S]*?)```/g,
'<div class="terminal-code">$1</div>',
);
// List items (- item)
text = text.replace(/^-\s+(.+)$/gm, " • $1");
// Checkmarks
text = text.replace(/^✓\s*/gm, '<span class="check-mark">✓</span> ');
return text;
}
function addTerminalLine(terminal, message, step, activity) {
const timestamp = new Date().toLocaleTimeString("en-US", { hour12: false });
const isLlmStream = step === "llm_stream";
// Determine line type based on content
const isHeader = message && message.startsWith("##");
const isSuccess = message && message.startsWith("✓");
const isError = step === "error";
const isComplete = step === "complete";
const stepClass = isError
? "error"
: isComplete || isSuccess
? "success"
: isHeader
? "progress"
: isLlmStream
? "llm-stream"
: "info";
// Format the message with markdown
const formattedMessage = formatTerminalMarkdown(message);
const line = document.createElement("div");
line.className = `terminal-line ${stepClass} current`;
if (isLlmStream) {
line.innerHTML = `<span class="llm-text">${formattedMessage}</span>`;
} else if (isHeader) {
line.innerHTML = formattedMessage;
} else {
line.innerHTML = `<span class="terminal-timestamp">${timestamp}</span>${formattedMessage}`;
}
// Remove 'current' class from previous lines
terminal.querySelectorAll(".terminal-line.current").forEach((el) => {
el.classList.remove("current");
});
terminal.appendChild(line);
terminal.scrollTop = terminal.scrollHeight;
// Keep only last 50 lines
while (terminal.children.length > 50) {
terminal.removeChild(terminal.firstChild);
}
}
// Update progress bar in detail panel
function updateDetailProgress(taskId, current, total, percent) {
const progressFill = document.querySelector(".progress-fill-rich");
const progressLabel = document.querySelector(".progress-label-rich");
const stepInfo = document.querySelector(".meta-estimated");
const pct = percent || (total > 0 ? Math.round((current / total) * 100) : 0);
if (progressFill) {
progressFill.style.width = `${pct}%`;
}
if (progressLabel) {
progressLabel.textContent = `Progress: ${pct}%`;
}
if (stepInfo) {
stepInfo.textContent = `Step ${current}/${total}`;
}
}
// Legacy functions kept for compatibility but now do nothing
function showFloatingProgress(taskName) {
// Progress now shown in detail panel terminal
console.log("[Tasks] Progress:", taskName);
}
function updateFloatingProgressBar(
current,
total,
message,
step,
details,
activity,
) {
// Progress now shown in detail panel
updateDetailProgress(null, current, total);
if (message) {
updateDetailTerminal(null, message, step, activity);
}
}
function completeFloatingProgress(message, activity, appUrl) {
// Completion now shown in detail panel
console.log("[Tasks] Complete:", message);
}
function closeFloatingProgress() {
// No floating panel to close
}
function minimizeFloatingProgress() {
// No floating panel to minimize
}
function updateProgressUI(data) {
if (data && data.current_step !== undefined) {
updateDetailProgress(
data.task_id,
data.current_step,
data.total_steps,
data.progress,
);
}
}
// Legacy function - errors now shown in detail panel
function errorFloatingProgress(errorMessage) {
updateDetailTerminal(null, errorMessage, "error");
}
function updateActivityMetrics(activity) {
// Activity metrics are now shown in terminal output
if (!activity) return;
console.log("[Tasks] Activity update:", activity);
}
function logFinalStats(activity) {
if (!activity) return;
let stats = "Generation complete";
if (activity.files_created)
stats += ` - ${activity.files_created.length} files`;
if (activity.bytes_processed)
stats += ` - ${Math.round(activity.bytes_processed / 1024)}KB`;
console.log("[Tasks]", stats);
}
function addLLMStreamOutput(text) {
// Add LLM streaming output to the floating terminal
const terminal = document.getElementById("floating-llm-terminal");
if (!terminal) return;
const line = document.createElement("div");
line.className = "llm-output";
line.textContent = text;
terminal.appendChild(line);
terminal.scrollTop = terminal.scrollHeight;
// Keep only last 100 lines to prevent memory issues
while (terminal.children.length > 100) {
terminal.removeChild(terminal.firstChild);
}
}
function updateProgressUI(data) {
const progressBar = document.querySelector(".result-progress-bar");
const resultDiv = document.getElementById("intent-result");
if (data.total_steps && data.current_step) {
const percent = Math.round((data.current_step / data.total_steps) * 100);
if (progressBar) {
progressBar.style.width = `${percent}%`;
}
if (resultDiv && data.message) {
resultDiv.innerHTML = `
<div class="result-card">
<div class="result-message">${data.message}</div>
<div class="result-progress">
<div class="result-progress-bar" style="width: ${percent}%"></div>
</div>
<div style="margin-top:8px;font-size:12px;color:var(--sentient-text-muted);">
Step ${data.current_step}/${data.total_steps} (${percent}%)
</div>
</div>
`;
}
}
}
// =============================================================================
// EVENT LISTENERS
// =============================================================================
function setupEventListeners() {
// Filter pills
document.querySelectorAll(".status-pill").forEach((pill) => {
pill.addEventListener("click", function (e) {
e.preventDefault();
const filter = this.dataset.filter;
setActiveFilter(filter, this);
});
});
// Search input
const searchInput = document.querySelector(".topbar-search-input");
if (searchInput) {
searchInput.addEventListener(
"input",
debounce(function (e) {
searchTasks(e.target.value);
}, 300),
);
}
// Nav items
document.querySelectorAll(".topbar-nav-item").forEach((item) => {
item.addEventListener("click", function () {
document
.querySelectorAll(".topbar-nav-item")
.forEach((i) => i.classList.remove("active"));
this.classList.add("active");
});
});
// Progress log toggle
const logToggle = document.querySelector(".progress-log-toggle");
if (logToggle) {
logToggle.addEventListener("click", toggleProgressLog);
}
}
function setupKeyboardShortcuts() {
document.addEventListener("keydown", function (e) {
// Escape: Deselect task
if (e.key === "Escape") {
deselectTask();
}
// Cmd/Ctrl + K: Focus search
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
document.querySelector(".topbar-search-input")?.focus();
}
// Arrow keys: Navigate tasks
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
navigateTasks(e.key === "ArrowDown" ? 1 : -1);
}
// Enter: Submit decision if in decision mode
if (
e.key === "Enter" &&
document.querySelector(".decision-option.selected")
) {
submitDecision();
}
// 1-5: Quick filter
if (e.key >= "1" && e.key <= "5" && !e.target.matches("input, textarea")) {
const pills = document.querySelectorAll(".status-pill");
const index = parseInt(e.key) - 1;
if (pills[index]) {
pills[index].click();
}
}
});
}
// =============================================================================
// TASK SELECTION & FILTERING
// =============================================================================
function selectTask(taskId) {
TasksState.selectedTaskId = taskId;
// Update selected state in list
document.querySelectorAll(".task-card").forEach((card) => {
card.classList.toggle("selected", card.dataset.taskId == taskId);
});
// Load task details (in real app, this would fetch from API)
loadTaskDetails(taskId);
}
function deselectTask() {
TasksState.selectedTaskId = null;
document.querySelectorAll(".task-card").forEach((card) => {
card.classList.remove("selected");
});
}
function navigateTasks(direction) {
const cards = Array.from(document.querySelectorAll(".task-card"));
if (cards.length === 0) return;
const currentIndex = cards.findIndex((c) => c.classList.contains("selected"));
let newIndex;
if (currentIndex === -1) {
newIndex = direction === 1 ? 0 : cards.length - 1;
} else {
newIndex = currentIndex + direction;
if (newIndex < 0) newIndex = cards.length - 1;
if (newIndex >= cards.length) newIndex = 0;
}
const taskId = cards[newIndex].dataset.taskId;
selectTask(taskId);
cards[newIndex].scrollIntoView({ behavior: "smooth", block: "nearest" });
}
function setActiveFilter(filter, button) {
TasksState.currentFilter = filter;
// Update active pill
document.querySelectorAll(".status-pill").forEach((pill) => {
pill.classList.remove("active");
});
button.classList.add("active");
// Filter will be handled by HTMX, but we track state
addAgentLog("info", `[FILTER] Showing ${filter} tasks`);
}
function searchTasks(query) {
if (query.length > 0) {
addAgentLog("info", `[SEARCH] Searching: "${query}"`);
}
// In real app, this would filter via API
// For demo, we'll do client-side filtering
const cards = document.querySelectorAll(".task-card");
cards.forEach((card) => {
const title =
card.querySelector(".task-card-title")?.textContent.toLowerCase() || "";
const subtitle =
card.querySelector(".task-card-subtitle")?.textContent.toLowerCase() ||
"";
const matches =
title.includes(query.toLowerCase()) ||
subtitle.includes(query.toLowerCase());
card.style.display = matches || query === "" ? "block" : "none";
});
}
// =============================================================================
// TASK DETAILS
// =============================================================================
function loadTaskDetails(taskId) {
if (!taskId) {
console.warn("[LOAD] No task ID provided");
return;
}
addAgentLog("info", `[LOAD] Loading task #${taskId} details`);
// Show detail panel and hide empty state
const emptyState = document.getElementById("detail-empty");
const detailContent = document.getElementById("task-detail-content");
if (!detailContent) {
console.error("[LOAD] task-detail-content element not found");
return;
}
if (emptyState) emptyState.style.display = "none";
detailContent.style.display = "block";
// Fetch task details from API - use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
if (typeof htmx !== "undefined" && htmx.ajax) {
htmx.ajax("GET", `/api/tasks/${taskId}`, {
target: "#task-detail-content",
swap: "innerHTML",
});
} else {
console.error("[LOAD] HTMX not available");
}
});
}
function updateTaskCard(task) {
const card = document.querySelector(`[data-task-id="${task.id}"]`);
if (!card) return;
// Update progress
const progressFill = card.querySelector(".task-progress-fill");
const progressPercent = card.querySelector(".task-progress-percent");
const progressSteps = card.querySelector(".task-progress-steps");
if (progressFill) progressFill.style.width = `${task.progress}%`;
if (progressPercent) progressPercent.textContent = `${task.progress}%`;
if (progressSteps)
progressSteps.textContent = `${task.currentStep}/${task.totalSteps} steps`;
// Update status badge
const statusBadge = card.querySelector(".task-card-status");
if (statusBadge) {
statusBadge.className = `task-card-status ${task.status}`;
statusBadge.textContent = formatStatus(task.status);
}
}
function updateTaskDetail(task) {
// Update detail panel with task data
const detailTitle = document.querySelector(".task-detail-title");
if (detailTitle) detailTitle.textContent = task.title;
}
// =============================================================================
// DECISION HANDLING
// =============================================================================
function selectDecision(element, value) {
// Remove selected from all options
document.querySelectorAll(".decision-option").forEach((opt) => {
opt.classList.remove("selected");
});
// Add selected to clicked option
element.classList.add("selected");
// Store selected value
TasksState.selectedDecision = value;
addAgentLog("info", `[DECISION] Selected: ${value}`);
}
function submitDecision() {
const selectedOption = document.querySelector(".decision-option.selected");
if (!selectedOption) {
showToast("Please select an option", "warning");
return;
}
const value = TasksState.selectedDecision;
const taskId = TasksState.selectedTaskId;
addAgentLog("accent", `[AGENT] Applying decision: ${value}`);
addAgentLog("info", `[TASK] Resuming task #${taskId}...`);
// In real app, send to API
fetch(`/api/tasks/${taskId}/decide`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: value }),
})
.then((response) => response.json())
.then((result) => {
if (result.success) {
showToast("Decision applied successfully", "success");
addAgentLog("success", `[OK] Decision applied, task resuming`);
// Hide decision section (in real app, would update via HTMX)
const decisionSection = document.querySelector(
".decision-required-section",
);
if (decisionSection) {
decisionSection.style.display = "none";
}
} else {
showToast("Failed to apply decision", "error");
addAgentLog(
"error",
`[ERROR] Failed to apply decision: ${result.error}`,
);
}
})
.catch((error) => {
// For demo, simulate success
showToast("Decision applied successfully", "success");
addAgentLog("success", `[OK] Decision applied, task resuming`);
const decisionSection = document.querySelector(
".decision-required-section",
);
if (decisionSection) {
decisionSection.style.opacity = "0.5";
setTimeout(() => {
decisionSection.style.display = "none";
}, 500);
}
// Update step status
const activeStep = document.querySelector(".step-item.active");
if (activeStep) {
activeStep.classList.remove("active");
activeStep.classList.add("completed");
activeStep.querySelector(".step-icon").textContent = "✓";
activeStep.querySelector(".step-detail").textContent =
"Completed with merge strategy";
const nextStep = activeStep.nextElementSibling;
if (nextStep && nextStep.classList.contains("pending")) {
nextStep.classList.remove("pending");
nextStep.classList.add("active");
nextStep.querySelector(".step-icon").textContent = "●";
nextStep.querySelector(".step-time").textContent = "Now";
}
}
});
}
function showDecisionRequired(decision) {
addAgentLog("warning", `[ALERT] Decision required: ${decision.title}`);
showToast(`Decision required: ${decision.title}`, "warning");
}
// =============================================================================
// PROGRESS LOG
// =============================================================================
function toggleProgressLog() {
const stepList = document.querySelector(".step-list");
const toggle = document.querySelector(".progress-log-toggle");
if (stepList.style.display === "none") {
stepList.style.display = "flex";
toggle.textContent = "Collapse";
} else {
stepList.style.display = "none";
toggle.textContent = "Expand";
}
}
function updateStepProgress(taskId, step) {
if (taskId !== TasksState.selectedTaskId) return;
const stepItems = document.querySelectorAll(".step-item");
stepItems.forEach((item, index) => {
if (index < step.index) {
item.classList.remove("active", "pending");
item.classList.add("completed");
item.querySelector(".step-icon").textContent = "✓";
} else if (index === step.index) {
item.classList.remove("completed", "pending");
item.classList.add("active");
item.querySelector(".step-icon").textContent = "●";
item.querySelector(".step-name").textContent = step.name;
item.querySelector(".step-detail").textContent = step.detail;
item.querySelector(".step-time").textContent = "Now";
} else {
item.classList.remove("completed", "active");
item.classList.add("pending");
item.querySelector(".step-icon").textContent = "○";
}
});
}
// =============================================================================
// AGENT ACTIVITY LOG
// =============================================================================
function addAgentLog(level, message) {
if (TasksState.agentLogPaused) return;
const log = document.getElementById("agent-log");
if (!log) return;
const now = new Date();
const timestamp = now.toTimeString().split(" ")[0].substring(0, 8);
const line = document.createElement("div");
line.className = `activity-line ${level}`;
line.innerHTML = `
<span class="activity-timestamp">${timestamp}</span>
<span class="activity-message">${message}</span>
`;
// Insert at the top
log.insertBefore(line, log.firstChild);
// Limit log entries
while (log.children.length > 100) {
log.removeChild(log.lastChild);
}
}
function scrollAgentLogToBottom() {
const log = document.getElementById("agent-log");
if (log) {
log.scrollTop = 0; // Since newest is at top
}
}
function clearAgentLog() {
const log = document.getElementById("agent-log");
if (log) {
log.innerHTML = "";
addAgentLog("info", "[SYSTEM] Log cleared");
}
}
function toggleAgentLogPause() {
TasksState.agentLogPaused = !TasksState.agentLogPaused;
const pauseBtn = document.querySelector(".agent-activity-btn:last-child");
if (pauseBtn) {
pauseBtn.textContent = TasksState.agentLogPaused ? "Resume" : "Pause";
}
addAgentLog(
"info",
TasksState.agentLogPaused ? "[SYSTEM] Log paused" : "[SYSTEM] Log resumed",
);
}
// =============================================================================
// TASK ACTIONS
// =============================================================================
function pauseTask(taskId) {
addAgentLog("info", `[TASK] Pausing task #${taskId}...`);
fetch(`/api/tasks/${taskId}/pause`, {
method: "POST",
headers: { "Content-Type": "application/json" },
})
.then((response) => response.json())
.then((result) => {
if (result.success) {
showToast("Task paused", "success");
addAgentLog("success", `[OK] Task #${taskId} paused`);
htmx.trigger(document.body, "taskCreated");
if (TasksState.selectedTaskId === taskId) {
loadTaskDetails(taskId);
}
} else {
showToast("Failed to pause task", "error");
addAgentLog(
"error",
`[ERROR] Failed to pause task: ${result.error || result.message}`,
);
}
})
.catch((error) => {
showToast("Failed to pause task", "error");
addAgentLog("error", `[ERROR] Failed to pause task: ${error}`);
});
}
function cancelTask(taskId) {
if (!confirm("Are you sure you want to cancel this task?")) {
return;
}
addAgentLog("info", `[TASK] Cancelling task #${taskId}...`);
fetch(`/api/tasks/${taskId}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
})
.then((response) => response.json())
.then((result) => {
if (result.success) {
showToast("Task cancelled", "success");
addAgentLog("success", `[OK] Task #${taskId} cancelled`);
htmx.trigger(document.body, "taskCreated");
if (TasksState.selectedTaskId === taskId) {
loadTaskDetails(taskId);
}
} else {
showToast("Failed to cancel task", "error");
addAgentLog(
"error",
`[ERROR] Failed to cancel task: ${result.error || result.message}`,
);
}
})
.catch((error) => {
showToast("Failed to cancel task", "error");
addAgentLog("error", `[ERROR] Failed to cancel task: ${error}`);
});
}
function showDetailedView(taskId) {
addAgentLog("info", `[TASK] Opening detailed view for task #${taskId}...`);
// For now, just reload the task details
// In the future, this could open a modal or new page with more details
loadTaskDetails(taskId);
showToast("Detailed view loaded", "info");
}
// =============================================================================
// TASK LIFECYCLE
// =============================================================================
function onTaskCompleted(data, appUrl) {
const title = data.title || data.message || "Task";
const taskId = data.task_id || data.id;
if (appUrl) {
showToast(`App ready! Click to open: ${appUrl}`, "success", 10000, () => {
window.open(appUrl, "_blank");
});
addAgentLog("success", `[COMPLETE] Task #${taskId}: ${title}`);
addAgentLog("success", `[URL] ${appUrl}`);
} else {
showToast(`Task completed: ${title}`, "success");
addAgentLog("success", `[COMPLETE] Task #${taskId}: ${title}`);
}
if (data.task) {
updateTaskCard(data.task);
}
}
function showAppUrlNotification(appUrl) {
// Create a prominent notification for the app URL
let notification = document.getElementById("app-url-notification");
if (!notification) {
notification = document.createElement("div");
notification.id = "app-url-notification";
notification.style.cssText = `
position: fixed;
top: 80px;
right: 24px;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white;
padding: 16px 24px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(34, 197, 94, 0.4);
z-index: 10001;
max-width: 400px;
animation: slideInRight 0.5s ease;
`;
document.body.appendChild(notification);
}
notification.innerHTML = `
<div style="font-weight: 600; margin-bottom: 8px;">🎉 App Created Successfully!</div>
<div style="font-size: 13px; opacity: 0.9; margin-bottom: 12px;">Your app is ready to use</div>
<a href="${appUrl}" target="_blank" style="
display: inline-block;
background: white;
color: #16a34a;
padding: 8px 16px;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
font-size: 14px;
">Open App →</a>
<button onclick="this.parentElement.remove()" style="
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 18px;
opacity: 0.7;
">×</button>
`;
// Auto-hide after 30 seconds
setTimeout(() => {
if (notification.parentElement) {
notification.style.animation = "slideOutRight 0.5s ease forwards";
setTimeout(() => notification.remove(), 500);
}
}, 30000);
}
function playCompletionSound() {
try {
// Create a simple beep sound using Web Audio API
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.frequency.value = 800;
oscillator.type = "sine";
gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
audioCtx.currentTime + 0.5,
);
oscillator.start(audioCtx.currentTime);
oscillator.stop(audioCtx.currentTime + 0.5);
// Play a second higher tone for success feel
setTimeout(() => {
const osc2 = audioCtx.createOscillator();
const gain2 = audioCtx.createGain();
osc2.connect(gain2);
gain2.connect(audioCtx.destination);
osc2.frequency.value = 1200;
osc2.type = "sine";
gain2.gain.setValueAtTime(0.3, audioCtx.currentTime);
gain2.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
osc2.start(audioCtx.currentTime);
osc2.stop(audioCtx.currentTime + 0.3);
}, 150);
} catch (e) {
console.log("[Tasks] Could not play completion sound:", e);
}
}
function onTaskFailed(task, error) {
showToast(`Task failed: ${task.title}`, "error");
addAgentLog("error", `[FAILED] Task #${task.id}: ${error}`);
updateTaskCard(task);
}
// =============================================================================
// TOAST NOTIFICATIONS
// =============================================================================
function showToast(message, type = "info", duration = 4000, onClick = null) {
let container = document.getElementById("toast-container");
if (!container) {
container = document.createElement("div");
container.id = "toast-container";
container.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 8px;
`;
document.body.appendChild(container);
}
const toast = document.createElement("div");
const bgColors = {
success: "rgba(34, 197, 94, 0.95)",
error: "rgba(239, 68, 68, 0.95)",
warning: "rgba(245, 158, 11, 0.95)",
info: "rgba(59, 130, 246, 0.95)",
};
const icons = {
success: "✓",
error: "✕",
warning: "⚠",
info: "",
};
toast.style.cssText = `
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: ${bgColors[type] || bgColors.info};
border-radius: 10px;
color: white;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease;
`;
toast.innerHTML = `
<span style="font-size: 16px;">${icons[type] || icons.info}</span>
<span>${message}</span>
`;
if (onClick) {
toast.style.cursor = "pointer";
toast.addEventListener("click", onClick);
}
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = "fadeOut 0.3s ease forwards";
setTimeout(() => toast.remove(), 300);
}, duration);
}
// =============================================================================
// UTILITY FUNCTIONS
// =============================================================================
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function formatStatus(status) {
const statusMap = {
complete: "Complete",
running: "Running",
awaiting: "Awaiting",
paused: "Paused",
blocked: "Blocked",
};
return statusMap[status] || status;
}
function formatTime(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return `${mins}m`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${hours}h ${mins}m`;
}
// =============================================================================
// GLOBAL STYLES FOR TOAST ANIMATIONS
// =============================================================================
const style = document.createElement("style");
style.textContent = `
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(20px);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideOutRight {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100px);
}
}
`;
document.head.appendChild(style);
// =============================================================================
// GOALS, PENDING INFO, SCHEDULERS, MONITORS
// =============================================================================
// Select a goal and show its details
window.selectGoal = function (goalId) {
TasksState.selectedItemType = "goal";
window.selectedTaskId = goalId;
document.querySelectorAll(".task-item, .task-card").forEach((el) => {
el.classList.remove("selected");
});
const selectedEl = document.querySelector(`[data-goal-id="${goalId}"]`);
if (selectedEl) {
selectedEl.classList.add("selected");
}
document.getElementById("task-detail-empty").style.display = "none";
document.getElementById("task-detail-content").style.display = "block";
// Hide other sections, show goal section
hideAllDetailSections();
document.getElementById("goal-progress-section").style.display = "block";
fetch(`/api/goals/${goalId}`)
.then((response) => response.json())
.then((goal) => {
document.getElementById("detail-title").textContent =
goal.goal_text || "Untitled Goal";
document.getElementById("detail-status-text").textContent =
goal.status || "active";
document.getElementById("detail-priority-text").textContent = "Goal";
document.getElementById("detail-description").textContent =
goal.goal_text || "";
const percent =
goal.target_value > 0
? Math.round((goal.current_value / goal.target_value) * 100)
: 0;
document.getElementById("goal-progress-fill").style.width = `${percent}%`;
document.getElementById("goal-current-value").textContent =
goal.current_value || 0;
document.getElementById("goal-target-value").textContent =
goal.target_value || 0;
document.getElementById("goal-percent").textContent = percent;
document.getElementById("goal-last-action").textContent = goal.last_action
? `Last action: ${goal.last_action}`
: "No actions yet";
})
.catch((err) => console.error("Failed to load goal:", err));
};
// Select a pending info item
window.selectPendingInfo = function (pendingId) {
TasksState.selectedItemType = "pending";
window.selectedTaskId = pendingId;
document.querySelectorAll(".task-item, .task-card").forEach((el) => {
el.classList.remove("selected");
});
const selectedEl = document.querySelector(`[data-pending-id="${pendingId}"]`);
if (selectedEl) {
selectedEl.classList.add("selected");
}
document.getElementById("task-detail-empty").style.display = "none";
document.getElementById("task-detail-content").style.display = "block";
hideAllDetailSections();
document.getElementById("pending-fill-section").style.display = "block";
fetch(`/api/pending-info/${pendingId}`)
.then((response) => response.json())
.then((pending) => {
document.getElementById("detail-title").textContent =
pending.field_label || "Pending Info";
document.getElementById("detail-status-text").textContent = "Pending";
document.getElementById("detail-priority-text").textContent =
pending.app_name || "";
document.getElementById("detail-description").textContent =
pending.reason || "";
document.getElementById("pending-reason").textContent =
pending.reason || "Required for app functionality";
document.getElementById("pending-fill-id").value = pending.id;
document.getElementById("pending-fill-label").textContent =
pending.field_label;
document.getElementById("pending-fill-value").type =
pending.field_type === "secret" ? "password" : "text";
})
.catch((err) => console.error("Failed to load pending info:", err));
};
// Select a scheduler
window.selectScheduler = function (schedulerName) {
TasksState.selectedItemType = "scheduler";
window.selectedTaskId = schedulerName;
document.querySelectorAll(".task-item, .task-card").forEach((el) => {
el.classList.remove("selected");
});
const selectedEl = document.querySelector(
`[data-scheduler-name="${schedulerName}"]`,
);
if (selectedEl) {
selectedEl.classList.add("selected");
}
document.getElementById("task-detail-empty").style.display = "none";
document.getElementById("task-detail-content").style.display = "block";
hideAllDetailSections();
document.getElementById("scheduler-info-section").style.display = "block";
fetch(`/api/schedulers/${encodeURIComponent(schedulerName)}`)
.then((response) => response.json())
.then((scheduler) => {
document.getElementById("detail-title").textContent =
scheduler.name || schedulerName;
document.getElementById("detail-status-text").textContent =
scheduler.status || "active";
document.getElementById("detail-priority-text").textContent = "Scheduler";
document.getElementById("detail-description").textContent =
scheduler.description || "";
document.getElementById("scheduler-cron").textContent =
scheduler.cron || "-";
document.getElementById("scheduler-next").textContent = scheduler.next_run
? `Next run: ${new Date(scheduler.next_run).toLocaleString()}`
: "Next run: -";
document.getElementById("scheduler-file").textContent = scheduler.file
? `File: ${scheduler.file}`
: "File: -";
})
.catch((err) => console.error("Failed to load scheduler:", err));
};
// Select a monitor
window.selectMonitor = function (monitorName) {
TasksState.selectedItemType = "monitor";
window.selectedTaskId = monitorName;
document.querySelectorAll(".task-item, .task-card").forEach((el) => {
el.classList.remove("selected");
});
const selectedEl = document.querySelector(
`[data-monitor-name="${monitorName}"]`,
);
if (selectedEl) {
selectedEl.classList.add("selected");
}
document.getElementById("task-detail-empty").style.display = "none";
document.getElementById("task-detail-content").style.display = "block";
hideAllDetailSections();
document.getElementById("monitor-info-section").style.display = "block";
fetch(`/api/monitors/${encodeURIComponent(monitorName)}`)
.then((response) => response.json())
.then((monitor) => {
document.getElementById("detail-title").textContent =
monitor.name || monitorName;
document.getElementById("detail-status-text").textContent =
monitor.status || "active";
document.getElementById("detail-priority-text").textContent = "Monitor";
document.getElementById("detail-description").textContent =
monitor.description || "";
document.getElementById("monitor-target").textContent = monitor.target
? `Target: ${monitor.target}`
: "Target: -";
document.getElementById("monitor-interval").textContent = monitor.interval
? `Interval: ${monitor.interval}`
: "Interval: -";
document.getElementById("monitor-last-check").textContent =
monitor.last_check
? `Last check: ${new Date(monitor.last_check).toLocaleString()}`
: "Last check: -";
document.getElementById("monitor-last-value").textContent =
monitor.last_value
? `Last value: ${monitor.last_value}`
: "Last value: -";
})
.catch((err) => console.error("Failed to load monitor:", err));
};
// Hide all detail sections
function hideAllDetailSections() {
document.getElementById("goal-progress-section").style.display = "none";
document.getElementById("pending-fill-section").style.display = "none";
document.getElementById("scheduler-info-section").style.display = "none";
document.getElementById("monitor-info-section").style.display = "none";
}
// Fill pending info form submission
document.addEventListener("htmx:afterRequest", function (event) {
if (event.detail.elt.id === "pending-fill-form" && event.detail.successful) {
htmx.trigger(document.body, "taskCreated");
document.getElementById("pending-fill-value").value = "";
addAgentLog("success", "[OK] Pending info filled successfully");
}
});
// Update counts for new filters
function updateFilterCounts() {
fetch("/api/tasks/stats/json")
.then((response) => response.json())
.then((stats) => {
if (stats.total !== undefined) {
const el = document.getElementById("count-all");
if (el) el.textContent = stats.total;
}
if (stats.completed !== undefined) {
const el = document.getElementById("count-complete");
if (el) el.textContent = stats.completed;
}
if (stats.active !== undefined) {
const el = document.getElementById("count-active");
if (el) el.textContent = stats.active;
}
if (stats.awaiting !== undefined) {
const el = document.getElementById("count-awaiting");
if (el) el.textContent = stats.awaiting;
}
if (stats.paused !== undefined) {
const el = document.getElementById("count-paused");
if (el) el.textContent = stats.paused;
}
if (stats.blocked !== undefined) {
const el = document.getElementById("count-blocked");
if (el) el.textContent = stats.blocked;
}
if (stats.time_saved !== undefined) {
const el = document.getElementById("time-saved-value");
if (el) el.textContent = stats.time_saved;
}
})
.catch((e) => console.warn("Failed to load task stats:", e));
}
// Call updateFilterCounts on load
document.addEventListener("DOMContentLoaded", updateFilterCounts);
document.body.addEventListener("taskCreated", updateFilterCounts);