- Fix loadTaskStats to use /api/tasks/stats/json endpoint - Add missing JS functions: pauseTask, cancelTask, showDetailedView - Add htmx:afterSwap listener to reinitialize tasks app on HTMX load - Prevent duplicate WebSocket connections - Add task creation feedback in UI (success/error messages) - Add tasks.js script to index.html
628 lines
24 KiB
HTML
628 lines
24 KiB
HTML
<!-- =============================================================================
|
||
TASKS APP - Autonomous Task Management
|
||
Respects Theme Manager - No hardcoded theme
|
||
============================================================================= -->
|
||
|
||
<div class="tasks-app">
|
||
<!-- Hidden element to load stats on page load -->
|
||
<div
|
||
hx-get="/api/tasks/stats"
|
||
hx-trigger="load, taskCreated from:body"
|
||
hx-swap="innerHTML"
|
||
style="display: none"
|
||
></div>
|
||
|
||
<!-- Status Filter Pills Row -->
|
||
<div class="status-filter-row">
|
||
<button
|
||
class="filter-pill"
|
||
data-filter="complete"
|
||
hx-get="/api/tasks?filter=complete"
|
||
hx-target="#task-list"
|
||
hx-swap="innerHTML"
|
||
>
|
||
<span class="pill-icon">✓</span>
|
||
<span class="pill-label">Complete</span>
|
||
<span class="pill-count" id="count-complete">-</span>
|
||
</button>
|
||
<button
|
||
class="filter-pill active"
|
||
data-filter="all"
|
||
hx-get="/api/tasks?filter=all"
|
||
hx-target="#task-list"
|
||
hx-swap="innerHTML"
|
||
>
|
||
<span class="pill-icon">📋</span>
|
||
<span class="pill-label">All Tasks</span>
|
||
<span class="pill-count" id="count-all">-</span>
|
||
</button>
|
||
<button
|
||
class="filter-pill"
|
||
data-filter="active"
|
||
hx-get="/api/tasks?filter=active"
|
||
hx-target="#task-list"
|
||
hx-swap="innerHTML"
|
||
>
|
||
<span class="pill-icon">⚡</span>
|
||
<span class="pill-label">Active Intents</span>
|
||
<span class="pill-count" id="count-active">-</span>
|
||
</button>
|
||
<button
|
||
class="filter-pill"
|
||
data-filter="awaiting"
|
||
hx-get="/api/tasks?filter=awaiting"
|
||
hx-target="#task-list"
|
||
hx-swap="innerHTML"
|
||
>
|
||
<span class="pill-icon">⏳</span>
|
||
<span class="pill-label">Awaiting Decision</span>
|
||
<span class="pill-count" id="count-awaiting">-</span>
|
||
</button>
|
||
<button
|
||
class="filter-pill"
|
||
data-filter="paused"
|
||
hx-get="/api/tasks?filter=paused"
|
||
hx-target="#task-list"
|
||
hx-swap="innerHTML"
|
||
>
|
||
<span class="pill-icon">⏸</span>
|
||
<span class="pill-label">Paused</span>
|
||
<span class="pill-count" id="count-paused">-</span>
|
||
</button>
|
||
<button
|
||
class="filter-pill"
|
||
data-filter="blocked"
|
||
hx-get="/api/tasks?filter=blocked"
|
||
hx-target="#task-list"
|
||
hx-swap="innerHTML"
|
||
>
|
||
<span class="pill-icon">⚠</span>
|
||
<span class="pill-label">Blocked/Issues</span>
|
||
<span class="pill-count" id="count-blocked">-</span>
|
||
</button>
|
||
<div class="time-saved-badge">
|
||
<span class="time-label">Active Time Saved:</span>
|
||
<span class="time-value" id="time-saved-value">-</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick Intent Input -->
|
||
<div class="quick-intent-bar">
|
||
<div class="intent-input-wrapper">
|
||
<input
|
||
type="text"
|
||
id="quick-intent-input"
|
||
name="intent"
|
||
class="quick-intent-input"
|
||
placeholder="What would you like to do? e.g., 'create a CRM app' or 'remind me to call John tomorrow'"
|
||
autocomplete="off"
|
||
/>
|
||
<button
|
||
id="quick-intent-btn"
|
||
class="btn-create-run"
|
||
hx-post="/api/autotask/create"
|
||
hx-ext="json-enc"
|
||
hx-include="#quick-intent-input"
|
||
hx-target="#intent-result-hidden"
|
||
hx-swap="none"
|
||
hx-indicator="#intent-spinner"
|
||
hx-timeout="300000"
|
||
>
|
||
<span class="btn-text">Create & Run</span>
|
||
<span class="spinner" id="intent-spinner"></span>
|
||
</button>
|
||
</div>
|
||
<div id="intent-result" class="intent-result"></div>
|
||
<div id="intent-result-hidden" style="display: none"></div>
|
||
</div>
|
||
|
||
<!-- Main Two-Column Layout -->
|
||
<main class="tasks-main">
|
||
<!-- Left Panel: Task Cards List -->
|
||
<section class="tasks-list-panel">
|
||
<div
|
||
class="tasks-list-scroll"
|
||
id="task-list"
|
||
hx-get="/api/tasks?filter=all"
|
||
hx-trigger="load, taskCreated from:body"
|
||
hx-swap="innerHTML"
|
||
>
|
||
<!-- Loading state - replaced by HTMX -->
|
||
<div class="loading-state">
|
||
<div class="loading-spinner"></div>
|
||
<p>Loading tasks...</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Right Panel: Task Detail -->
|
||
<aside class="task-detail-panel" id="task-detail-panel">
|
||
<!-- Detail content loaded dynamically -->
|
||
<div class="detail-empty" id="detail-empty">
|
||
<div class="empty-icon">📋</div>
|
||
<h3 class="empty-title">Select a task</h3>
|
||
<p class="empty-description">
|
||
Click on a task from the list to view details
|
||
</p>
|
||
<div class="empty-info">
|
||
<p>
|
||
<strong>Bot Database:</strong> All apps share the same
|
||
database tables.
|
||
</p>
|
||
<p>
|
||
<strong>Shared Resources:</strong> Schedulers, tools,
|
||
and monitors work across all apps.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dynamic detail content -->
|
||
<div
|
||
id="task-detail-content"
|
||
style="display: none"
|
||
hx-get=""
|
||
hx-trigger="taskSelected from:body"
|
||
hx-swap="innerHTML"
|
||
>
|
||
<!-- Loaded via HTMX when task selected -->
|
||
</div>
|
||
</aside>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Floating Progress Panel - Shows live task generation progress -->
|
||
<div
|
||
class="floating-progress-panel"
|
||
id="floating-progress"
|
||
style="display: none"
|
||
>
|
||
<div class="floating-progress-header">
|
||
<div class="floating-progress-title">
|
||
<span class="progress-dot"></span>
|
||
<span id="floating-task-name">Processing...</span>
|
||
</div>
|
||
<div class="floating-progress-actions">
|
||
<button class="btn-minimize" onclick="minimizeFloatingProgress()">
|
||
—
|
||
</button>
|
||
<button class="btn-close-float" onclick="closeFloatingProgress()">
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="floating-progress-body">
|
||
<div class="floating-progress-bar">
|
||
<div
|
||
class="floating-progress-fill"
|
||
id="floating-progress-fill"
|
||
style="width: 0%"
|
||
></div>
|
||
</div>
|
||
<div class="floating-progress-info">
|
||
<span id="floating-progress-step">Starting...</span>
|
||
<span id="floating-progress-percent">0%</span>
|
||
</div>
|
||
<div class="floating-progress-log" id="floating-progress-log">
|
||
<!-- Live log entries appear here -->
|
||
</div>
|
||
<div class="floating-llm-terminal" id="floating-llm-terminal">
|
||
<!-- LLM streaming output appears here -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast Container -->
|
||
<div class="toast-container" id="toast-container"></div>
|
||
|
||
<!-- New Intent Modal -->
|
||
<div class="modal" id="new-intent-modal" style="display: none">
|
||
<div class="modal-backdrop" onclick="closeNewIntentModal()"></div>
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Create New Intent</h3>
|
||
<button class="btn-close" onclick="closeNewIntentModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="new-intent-form">
|
||
<div class="form-group">
|
||
<label for="intent-text"
|
||
>What would you like to accomplish?</label
|
||
>
|
||
<textarea
|
||
id="intent-text"
|
||
name="intent"
|
||
rows="4"
|
||
placeholder="Describe what you want to create or automate..."
|
||
></textarea>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="intent-priority">Priority</label>
|
||
<select id="intent-priority" name="priority">
|
||
<option value="normal">Normal</option>
|
||
<option value="high">High</option>
|
||
<option value="low">Low</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="intent-mode">Execution Mode</label>
|
||
<select id="intent-mode" name="mode">
|
||
<option value="auto">Automatic</option>
|
||
<option value="supervised">Supervised</option>
|
||
<option value="manual">Manual Approval</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn-secondary" onclick="closeNewIntentModal()">
|
||
Cancel
|
||
</button>
|
||
<button class="btn-primary" onclick="submitNewIntent()">
|
||
Create & Run
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Decision Modal -->
|
||
<div class="modal" id="decision-modal" style="display: none">
|
||
<div class="modal-backdrop" onclick="closeDecisionModal()"></div>
|
||
<div class="modal-content modal-lg">
|
||
<div class="modal-header">
|
||
<h3>Make Decision</h3>
|
||
<button class="btn-close" onclick="closeDecisionModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="decision-question" id="decision-question">
|
||
<h4>Decision Required</h4>
|
||
<p>Loading decision details...</p>
|
||
</div>
|
||
<div class="decision-options" id="decision-options">
|
||
<!-- Options loaded dynamically -->
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn-secondary" onclick="closeDecisionModal()">
|
||
Cancel
|
||
</button>
|
||
<button class="btn-secondary" onclick="skipDecision()">
|
||
Skip for Now
|
||
</button>
|
||
<button class="btn-primary" onclick="submitDecision()">
|
||
Confirm Decision
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// Initialize on load
|
||
document.addEventListener("DOMContentLoaded", function () {
|
||
if (typeof initTasksApp === "function") {
|
||
initTasksApp();
|
||
}
|
||
// Load stats
|
||
loadTaskStats();
|
||
|
||
// Handle create task response - auto-select new task and show progress
|
||
document.body.addEventListener("htmx:afterRequest", function (evt) {
|
||
// Check if this is the create task response
|
||
if (
|
||
evt.detail.pathInfo &&
|
||
evt.detail.pathInfo.requestPath === "/api/autotask/create"
|
||
) {
|
||
const xhr = evt.detail.xhr;
|
||
const intentResult = document.getElementById("intent-result");
|
||
|
||
if (xhr && xhr.status === 202) {
|
||
try {
|
||
const response = JSON.parse(xhr.responseText);
|
||
if (response.success && response.task_id) {
|
||
console.log(
|
||
"[TASK] Created task:",
|
||
response.task_id,
|
||
);
|
||
|
||
// Show success feedback
|
||
intentResult.innerHTML = `<span class="intent-success">✓ Task created - running...</span>`;
|
||
intentResult.style.display = "block";
|
||
|
||
// Clear the input
|
||
document.getElementById(
|
||
"quick-intent-input",
|
||
).value = "";
|
||
|
||
// Trigger task list refresh
|
||
htmx.trigger(document.body, "taskCreated");
|
||
|
||
// After a short delay to let the list reload, select the new task
|
||
setTimeout(function () {
|
||
selectTask(response.task_id);
|
||
// Start polling for updates
|
||
startTaskPolling(response.task_id);
|
||
// Hide success message after task is selected
|
||
setTimeout(function () {
|
||
intentResult.style.display = "none";
|
||
}, 2000);
|
||
}, 500);
|
||
} else {
|
||
intentResult.innerHTML = `<span class="intent-error">✗ ${response.message || "Failed to create task"}</span>`;
|
||
intentResult.style.display = "block";
|
||
}
|
||
} catch (e) {
|
||
console.warn("Failed to parse create response:", e);
|
||
intentResult.innerHTML = `<span class="intent-error">✗ Failed to parse response</span>`;
|
||
intentResult.style.display = "block";
|
||
}
|
||
} else if (xhr && xhr.status >= 400) {
|
||
try {
|
||
const response = JSON.parse(xhr.responseText);
|
||
intentResult.innerHTML = `<span class="intent-error">✗ ${response.error || response.message || "Error creating task"}</span>`;
|
||
} catch (e) {
|
||
intentResult.innerHTML = `<span class="intent-error">✗ Error: ${xhr.status}</span>`;
|
||
}
|
||
intentResult.style.display = "block";
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// Poll for task status updates
|
||
let taskPollingInterval = null;
|
||
function startTaskPolling(taskId) {
|
||
// Clear any existing polling
|
||
if (taskPollingInterval) {
|
||
clearInterval(taskPollingInterval);
|
||
}
|
||
|
||
taskPollingInterval = setInterval(function () {
|
||
fetch(`/api/autotask/${taskId}`)
|
||
.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
|
||
function loadTaskStats() {
|
||
fetch("/api/tasks/stats/json")
|
||
.then((r) => r.json())
|
||
.then((stats) => {
|
||
if (stats.complete !== undefined)
|
||
document.getElementById("count-complete").textContent =
|
||
stats.complete;
|
||
if (stats.active !== undefined)
|
||
document.getElementById("count-active").textContent =
|
||
stats.active;
|
||
if (stats.awaiting !== undefined)
|
||
document.getElementById("count-awaiting").textContent =
|
||
stats.awaiting;
|
||
if (stats.paused !== undefined)
|
||
document.getElementById("count-paused").textContent =
|
||
stats.paused;
|
||
if (stats.blocked !== undefined)
|
||
document.getElementById("count-blocked").textContent =
|
||
stats.blocked;
|
||
if (stats.time_saved !== undefined)
|
||
document.getElementById("time-saved-value").textContent =
|
||
stats.time_saved;
|
||
})
|
||
.catch((e) => console.warn("Failed to load stats:", e));
|
||
}
|
||
|
||
// Filter pill click handler
|
||
document.querySelectorAll(".filter-pill").forEach((pill) => {
|
||
pill.addEventListener("click", function () {
|
||
document
|
||
.querySelectorAll(".filter-pill")
|
||
.forEach((p) => p.classList.remove("active"));
|
||
this.classList.add("active");
|
||
});
|
||
});
|
||
|
||
// Modal functions
|
||
function showNewIntentModal() {
|
||
document.getElementById("new-intent-modal").style.display = "flex";
|
||
}
|
||
|
||
function closeNewIntentModal() {
|
||
document.getElementById("new-intent-modal").style.display = "none";
|
||
}
|
||
|
||
function showDecisionModal(decision) {
|
||
if (decision) {
|
||
document.getElementById("decision-question").innerHTML = `
|
||
<h4>${decision.title || "Decision Required"}</h4>
|
||
<p>${decision.description || ""}</p>
|
||
`;
|
||
}
|
||
document.getElementById("decision-modal").style.display = "flex";
|
||
}
|
||
|
||
function closeDecisionModal() {
|
||
document.getElementById("decision-modal").style.display = "none";
|
||
}
|
||
|
||
function submitNewIntent() {
|
||
const form = document.getElementById("new-intent-form");
|
||
const intent = form.querySelector('[name="intent"]').value;
|
||
if (intent.trim()) {
|
||
document.getElementById("quick-intent-input").value = intent;
|
||
htmx.trigger(document.getElementById("quick-intent-btn"), "click");
|
||
closeNewIntentModal();
|
||
}
|
||
}
|
||
|
||
function submitDecision() {
|
||
// Implementation for submitting decision
|
||
closeDecisionModal();
|
||
htmx.trigger(document.body, "taskCreated");
|
||
}
|
||
|
||
function skipDecision() {
|
||
closeDecisionModal();
|
||
}
|
||
|
||
// Task selection
|
||
function selectTask(taskId) {
|
||
// Update selection UI
|
||
document.querySelectorAll(".task-card").forEach((card) => {
|
||
card.classList.remove("selected");
|
||
});
|
||
const selectedCard = document.querySelector(
|
||
`[data-task-id="${taskId}"]`,
|
||
);
|
||
if (selectedCard) {
|
||
selectedCard.classList.add("selected");
|
||
}
|
||
|
||
// Show detail panel
|
||
document.getElementById("detail-empty").style.display = "none";
|
||
const detailContent = document.getElementById("task-detail-content");
|
||
detailContent.style.display = "block";
|
||
|
||
// Load task details
|
||
htmx.ajax("GET", `/api/tasks/${taskId}`, {
|
||
target: "#task-detail-content",
|
||
swap: "innerHTML",
|
||
});
|
||
}
|
||
|
||
function deselectTask() {
|
||
document.querySelectorAll(".task-card").forEach((card) => {
|
||
card.classList.remove("selected");
|
||
});
|
||
document.getElementById("detail-empty").style.display = "flex";
|
||
document.getElementById("task-detail-content").style.display = "none";
|
||
}
|
||
|
||
// Floating Progress Panel Functions
|
||
function showFloatingProgress(taskName) {
|
||
let panel = document.getElementById("floating-progress");
|
||
document.getElementById("floating-task-name").textContent =
|
||
taskName || "Processing...";
|
||
document.getElementById("floating-progress-fill").style.width = "0%";
|
||
document.getElementById("floating-progress-step").textContent =
|
||
"Starting...";
|
||
document.getElementById("floating-progress-percent").textContent = "0%";
|
||
document.getElementById("floating-progress-log").innerHTML = "";
|
||
const dot = panel.querySelector(".progress-dot");
|
||
if (dot) dot.classList.remove("completed", "error");
|
||
panel.style.display = "block";
|
||
panel.classList.remove("minimized");
|
||
}
|
||
|
||
function updateFloatingProgress(step, message, current, total, details) {
|
||
const panel = document.getElementById("floating-progress");
|
||
if (panel.style.display === "none") {
|
||
showFloatingProgress(message);
|
||
}
|
||
|
||
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
||
document.getElementById("floating-progress-fill").style.width =
|
||
percent + "%";
|
||
document.getElementById("floating-progress-step").textContent = message;
|
||
document.getElementById("floating-progress-percent").textContent =
|
||
percent + "%";
|
||
|
||
// Add log entry
|
||
if (step) {
|
||
const log = document.getElementById("floating-progress-log");
|
||
const entry = document.createElement("div");
|
||
entry.className = "log-entry";
|
||
const time = new Date().toLocaleTimeString();
|
||
entry.innerHTML = `<span class="log-time">${time}</span> <span class="log-step">[${step}]</span> ${message}`;
|
||
if (details) {
|
||
entry.innerHTML += `<br><span class="log-details">→ ${details}</span>`;
|
||
}
|
||
log.appendChild(entry);
|
||
log.scrollTop = log.scrollHeight;
|
||
}
|
||
}
|
||
|
||
function completeFloatingProgress(message) {
|
||
document.getElementById("floating-progress-fill").style.width = "100%";
|
||
document.getElementById("floating-progress-step").textContent =
|
||
message || "Completed!";
|
||
document.getElementById("floating-progress-percent").textContent =
|
||
"100%";
|
||
const panel = document.getElementById("floating-progress");
|
||
const dot = panel.querySelector(".progress-dot");
|
||
if (dot) dot.classList.add("completed");
|
||
|
||
// Refresh task list
|
||
htmx.trigger(document.body, "taskCreated");
|
||
loadTaskStats();
|
||
|
||
// Auto-hide after 5 seconds
|
||
setTimeout(closeFloatingProgress, 5000);
|
||
}
|
||
|
||
function errorFloatingProgress(errorMessage) {
|
||
document.getElementById("floating-progress-step").textContent =
|
||
"Error: " + errorMessage;
|
||
const panel = document.getElementById("floating-progress");
|
||
const dot = panel.querySelector(".progress-dot");
|
||
if (dot) dot.classList.add("error");
|
||
}
|
||
|
||
function minimizeFloatingProgress() {
|
||
document
|
||
.getElementById("floating-progress")
|
||
.classList.toggle("minimized");
|
||
}
|
||
|
||
function closeFloatingProgress() {
|
||
const panel = document.getElementById("floating-progress");
|
||
panel.style.display = "none";
|
||
const dot = panel.querySelector(".progress-dot");
|
||
if (dot) dot.classList.remove("completed", "error");
|
||
}
|
||
|
||
// Listen for HTMX events to refresh stats
|
||
document.body.addEventListener("htmx:afterSwap", function (e) {
|
||
if (e.detail.target.id === "task-list") {
|
||
loadTaskStats();
|
||
// Show empty state if no tasks
|
||
const taskList = document.getElementById("task-list");
|
||
const emptyState = document.getElementById("empty-state");
|
||
if (taskList && emptyState) {
|
||
const hasTasks = taskList.querySelector(".task-card");
|
||
emptyState.style.display = hasTasks ? "none" : "flex";
|
||
}
|
||
}
|
||
});
|
||
</script>
|