// ClawdDotNet — Aufgaben (Taskboard) // // Central taskboard view for scheduled and manual tasks. // // Since #594 the only place for scheduled work. The former tab "Scheduled runs (Jobs)" // edited the legacy config scheduler/toolJobs; after the first migration into the taskboard // no change made there had any effect. Schedule, next and last run, pause and run-now // now live on the task itself. import { api } from "../modules/api.js"; import { t } from "../modules/i18n.js"; import { erstelleElement, erstelleIcon, PFADE, erstellePlakette, erstelleKarte, erstelleModal, formatiereDatumZeit, formatiereKosten, zeigeMeldung } from "../modules/dom.js"; function holeStatusText(status) { switch (status) { case "Backlog": return t("status.paused"); case "Todo": return t("status.ready"); case "InProgress": return t("status.running"); case "InReview": return t("status.inReview"); case "Done": return t("status.done"); case "Canceled": return t("status.canceled"); case "Blocked": return t("status.blocked"); case "Archived": return t("status.archived"); default: return status || t("common.unknown"); } } const STATUS_ART = { Backlog: "warnung", Todo: "neutral", InProgress: "laufend", Done: "erfolg", Blocked: "warnung" }; const LAUF_ART = { Completed: "erfolg", Truncated: "warnung", Incomplete: "warnung", BudgetExceeded: "warnung", EmptyResponse: "gefahr", RepeatedFailure: "gefahr", LoopLimitExceeded: "gefahr", Failed: "gefahr" }; const ABGESCHLOSSEN = ["Done", "Canceled", "Archived"]; let zeigeAbgeschlossene = false; /** #684: The board shown — "" for all, "-" for the top level, otherwise a unit id. */ let brettFilter = ""; export async function rendereAufgaben() { const container = document.getElementById("ansicht-inhalt"); if (!container) return; container.replaceChildren(); const layout = erstelleElement("div", { klasse: "aufgaben-ansicht" }); const kopf = erstelleElement("div", { klasse: "ansicht-kopf" }); const abgeschlossenSchalter = erstelleElement( "label", { klasse: "gruppe-horizontal text-klein" }, erstelleElement("input", { type: "checkbox", checked: zeigeAbgeschlossene, onchange: (e) => { zeigeAbgeschlossene = e.target.checked; ladeTaskboard(); } }), erstelleElement("span", { text: t("tasks.showCompleted") }) ); const neuladen = erstelleElement( "button", { klasse: "schaltflaeche flach", title: t("common.refresh"), onclick: () => ladeTaskboard() }, erstelleIcon(PFADE.aktualisieren, 16) ); const neu = erstelleElement( "button", { klasse: "schaltflaeche primaer", onclick: () => oeffneAufgabenDialog(null) }, erstelleIcon(PFADE.plus, 16), erstelleElement("span", { text: " " + t("tasks.newTask") }) ); kopf.append( erstelleElement("h1", { text: t("tasks.title") }), erstelleElement("div", { klasse: "gruppe-horizontal" }, abgeschlossenSchalter, neuladen, neu) ); layout.append(kopf, erstelleElement("div", { id: "aufgaben-bereich-inhalt" })); container.append(layout); await ladeTaskboard(); } async function ladeTaskboard() { const container = document.getElementById("aufgaben-bereich-inhalt"); if (!container) return; container.replaceChildren( erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }) ); try { const alle = (await api("/tasks?limit=200")) || []; const offen = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status)); const tasks = offen.filter((t) => !brettFilter || (brettFilter === "-" ? !t.board : t.board === brettFilter)); container.replaceChildren(); // #684: With units, every task is on a unit's board — pick one to see only that. const bretter = brettAuswahl(alle); if (bretter) container.append(bretter); if (tasks.length === 0) { container.append( erstelleKarte(t("tasks.empty"), [ erstelleElement("p", { klasse: "gedaempft", text: "..." }) ]) ); return; } const raster = erstelleElement("div", { klasse: "taskboard-raster" }); for (const task of tasks) raster.append(erstelleAufgabenKarte(task)); container.append(raster); } catch (err) { container.replaceChildren( erstelleKarte("Taskboard Error", [ erstelleElement("p", { klasse: "gedaempft", text: err.message }) ]) ); } } /** * #684: The boards the tasks are on, as a filter. Built from the tasks themselves, so it * shows only boards that hold something; absent when no task is on a unit's board. */ function brettAuswahl(tasks) { const bretter = new Map(); for (const task of tasks) if (task.board) bretter.set(task.board, task.boardPath || task.board); if (bretter.size === 0) return null; const auswahl = erstelleElement("select", { "aria-label": t("tasks.board"), onchange: (e) => { brettFilter = e.target.value; ladeTaskboard(); } }, erstelleElement("option", { value: "", text: t("tasks.boardAll") }), erstelleElement("option", { value: "-", text: t("tasks.boardTopLevel") }), ...[...bretter.entries()] .sort((a, b) => a[1].localeCompare(b[1])) .map(([id, pfad]) => erstelleElement("option", { value: id, text: pfad }))); auswahl.value = brettFilter; return erstelleElement("label", { klasse: "gruppe-horizontal text-klein taskboard-bretter" }, erstelleElement("span", { text: t("tasks.board") }), auswahl); } function terminText(task) { if (!task.when) return t("tasks.scheduleNone"); const art = task.when.kind; return `${art} ${task.when.value} (${task.when.timeZone || "UTC"})`; } function erstelleAufgabenKarte(task) { const zeilen = []; zeilen.push( erstelleElement( "div", { klasse: "task-status-zeile gruppe-horizontal" }, erstellePlakette(holeStatusText(task.status), STATUS_ART[task.status] || "neutral"), erstelleElement("span", { klasse: "gedaempft text-klein", text: `${task.id} · ${task.assignee || "—"}${task.boardPath ? ` · ▤ ${task.boardPath}` : ""}${task.parentId ? ` · ↑ ${t("tasks.serves", { id: task.parentId })}` : ""}` }) ) ); zeilen.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: `${t("tasks.schedule")}: ${terminText(task)}` })); zeilen.push( erstelleElement("p", { klasse: "gedaempft text-klein", text: `${t("tasks.nextRun")}: ${task.nextRunUtc ? formatiereDatumZeit(task.nextRunUtc) : "—"}` }) ); const letzter = erstelleElement("p", { klasse: "gedaempft text-klein" }, `${t("tasks.lastRun")}: `); if (task.lastRun) { letzter.append( `${formatiereDatumZeit(task.lastRun.occurredAt)} `, erstellePlakette(task.lastRun.status, LAUF_ART[task.lastRun.status] || "neutral"), ` ${task.lastRun.costIsKnown ? formatiereKosten(task.lastRun.costUsd) : "—"}` ); if (task.lastRun.resultRef) letzter.title = task.lastRun.resultRef; } else { letzter.append("—"); } zeilen.push(letzter); if (task.hinweis) { zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${task.hinweis}` })); } // #716: What the budget refused, and what happens next. const ausfall = task.budgetMiss; if (ausfall) { const teile = [t("tasks.budgetMissed", { n: ausfall.count })]; if (ausfall.lastAt) teile.push(t("tasks.budgetMissedLast", { time: formatiereDatumZeit(ausfall.lastAt) })); if (ausfall.carriedOver && ausfall.deferredUntil) { teile.push(t("tasks.budgetCarriedOver", { time: formatiereDatumZeit(ausfall.deferredUntil) })); } else if (ausfall.catchUpPending) { teile.push(t("tasks.budgetCatchUp", { time: ausfall.deferredUntil ? formatiereDatumZeit(ausfall.deferredUntil) : "—" })); } zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${teile.join(" · ")}` })); } const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal", stil: "margin-top: 8px; justify-content: flex-end;" }); if (task.assignee !== "@human" && task.status === "Todo") { aktionen.append( erstelleElement( "button", { klasse: "schaltflaeche flach klein", title: t("tasks.runNow"), onclick: () => fuehreAus(task) }, erstelleIcon(PFADE.abspielen, 14), erstelleElement("span", { text: " " + t("tasks.runNow") }) ) ); } if (task.status === "Todo") { aktionen.append( erstelleElement("button", { klasse: "schaltflaeche flach klein", text: t("tasks.pause"), onclick: () => setzeAktiv(task, false) }) ); } else if (["Backlog", "Done", "Canceled"].includes(task.status)) { aktionen.append( erstelleElement("button", { klasse: "schaltflaeche flach klein", text: t("tasks.resume"), onclick: () => setzeAktiv(task, true) }) ); } aktionen.append( erstelleElement("button", { klasse: "schaltflaeche flach klein", text: t("common.edit"), onclick: () => oeffneAufgabenDialog(task) }) ); zeilen.push(aktionen); return erstelleKarte(task.title || `Task ${task.id}`, zeilen); } async function fuehreAus(task) { try { const res = await api(`/tasks/${encodeURIComponent(task.id)}/run`, { method: "POST" }); zeigeMeldung(res?.message || t("tasks.runTriggered"), "erfolg"); setTimeout(ladeTaskboard, 1500); } catch (err) { zeigeMeldung(`Error: ${err.message}`, "gefahr"); } } async function setzeAktiv(task, aktiv) { try { const res = await api(`/tasks/${encodeURIComponent(task.id)}/enabled?enabled=${aktiv}`, { method: "PUT" }); zeigeMeldung(res?.message || (aktiv ? t("tasks.resume") : t("tasks.pause")), "info"); await ladeTaskboard(); } catch (err) { zeigeMeldung(`Error: ${err.message}`, "gefahr"); } } async function oeffneAufgabenDialog(task) { let agenten = []; try { agenten = (await api("/agents")) || []; } catch { // } const formular = erstelleElement("form", { klasse: "formular-raster" }); const titel = erstelleElement("input", { type: "text", required: true, value: task?.title || "" }); const beschreibung = erstelleElement("textarea", { rows: "5", placeholder: "..." }); beschreibung.value = task?.body || ""; const zuordnung = erstelleElement("select", { required: true }); for (const a of agenten) { zuordnung.append( erstelleElement("option", { value: `@new:${a.agentId}`, text: `${a.displayName} (New Run)` }), erstelleElement("option", { value: `@${a.agentId}`, text: `${a.displayName} (Chat History)` }) ); } zuordnung.append(erstelleElement("option", { value: "@human", text: "Human (No Agent)" })); if (task?.assignee && ![...zuordnung.options].some((o) => o.value === task.assignee)) { zuordnung.append(erstelleElement("option", { value: task.assignee, text: task.assignee })); } zuordnung.value = task?.assignee || (agenten[0] ? `@new:${agenten[0].agentId}` : "@human"); const terminArt = erstelleElement("select"); terminArt.append( erstelleElement("option", { value: "", text: t("tasks.scheduleNone") }), erstelleElement("option", { value: "cron", text: "Cron" }), erstelleElement("option", { value: "every", text: t("tasks.scheduleInterval") }), erstelleElement("option", { value: "at", text: t("tasks.scheduleAt") }) ); terminArt.value = task?.when?.kind || ""; const terminWert = erstelleElement("input", { type: "text", value: task?.when?.value || "" }); const standardZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/Berlin"; const zeitzone = erstelleElement("input", { type: "text", value: task?.when?.timeZone || standardZone }); const abnahme = erstelleElement("textarea", { rows: "2", placeholder: "..." }); abnahme.value = task?.acceptance || ""; // #714: The task this one serves — the run is told the chain up to the unit's goal. const eltern = erstelleElement("input", { type: "text", value: task?.parentId || "", placeholder: "t-…" }); const hinweis = erstelleElement("p", { klasse: "gedaempft text-klein" }); const aktualisiere = () => { const wiederkehrend = terminArt.value === "cron" || terminArt.value === "every"; const imVerlauf = zuordnung.value.startsWith("@") && !zuordnung.value.startsWith("@new") && zuordnung.value !== "@human"; hinweis.hidden = !(wiederkehrend && imVerlauf); hinweis.textContent = "⚠ Notice: Running recurring tasks in chat history increases context and costs."; const ohneTermin = terminArt.value === ""; terminWert.disabled = ohneTermin; zeitzone.disabled = ohneTermin; terminWert.placeholder = { cron: "e.g. 0 7-22/3 * * *", every: "e.g. 30m, 2h, 1d", at: "e.g. 2026-09-15T08:00" }[terminArt.value] || ""; }; zuordnung.addEventListener("change", aktualisiere); terminArt.addEventListener("change", aktualisiere); aktualisiere(); formular.append( erstelleElement("label", { text: t("common.name") + ":" }), titel, erstelleElement("label", { text: t("tasks.taskMessage") + ":" }), beschreibung, erstelleElement("label", { text: t("tasks.agent") + ":" }), zuordnung, erstelleElement("label", { text: t("tasks.scheduleType") + ":" }), terminArt, erstelleElement("label", { text: "Value:" }), terminWert, erstelleElement("label", { text: "Timezone:" }), zeitzone, erstelleElement("label", { text: "Acceptance Criteria:" }), abnahme, erstelleElement("label", { text: t("tasks.parent") + ":" }), eltern, erstelleElement("span"), hinweis ); const abbrechen = erstelleElement("button", { type: "button", klasse: "schaltflaeche flach", text: t("common.cancel"), onclick: () => modal.schliessen() }); const speichern = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: task ? t("common.save") : t("tasks.newTask") }); const modal = erstelleModal(task ? t("common.edit") : t("tasks.createTitle"), formular, [abbrechen, speichern]); formular.addEventListener("submit", async (e) => { e.preventDefault(); speichern.disabled = true; const body = { title: titel.value.trim(), body: beschreibung.value.trim(), assignee: zuordnung.value, whenKind: terminArt.value || null, whenValue: terminArt.value ? terminWert.value.trim() : null, timeZone: terminArt.value ? zeitzone.value.trim() : null, acceptance: abnahme.value.trim(), priority: task?.priority ?? 3, parentId: eltern.value.trim() }; try { const res = await api(task ? `/tasks/${encodeURIComponent(task.id)}` : "/tasks", { method: task ? "PUT" : "POST", body }); modal.schliessen(); const text = res?.message || t("tasks.saved"); zeigeMeldung(text, "erfolg"); await ladeTaskboard(); } catch (err) { zeigeMeldung(`Error: ${err.message}`, "gefahr"); } finally { speichern.disabled = false; } }); modal.oeffnen(); }