const TASK_LANES = ["backlog", "committed", "in-progress", "blocked", "done", "canceled"]; const TASK_ACTION_LABELS = { backlog: "Backlog", committed: "Commit", "in-progress": "Start", blocked: "Block", done: "Done", canceled: "Cancel", }; const TASK_LANE_LABELS = { backlog: "Backlog", committed: "Committed", "in-progress": "In Progress", blocked: "Blocked", done: "Done", canceled: "Canceled", }; const TASK_LANE_THEMES = { backlog: { accent: "#5f7884", accentSoft: "rgba(95, 120, 132, 0.13)", muted: "#6b7e87", buttonInk: "#294a57", }, committed: { accent: "#8a6946", accentSoft: "rgba(138, 105, 70, 0.14)", muted: "#7f664a", buttonInk: "#5a3b19", }, "in-progress": { accent: "#4f7862", accentSoft: "rgba(79, 120, 98, 0.13)", muted: "#5e7768", buttonInk: "#214437", }, blocked: { accent: "#a55f4b", accentSoft: "rgba(165, 95, 75, 0.13)", muted: "#906659", buttonInk: "#6c2f21", }, done: { accent: "#6d7f58", accentSoft: "rgba(109, 127, 88, 0.12)", muted: "#6b755d", buttonInk: "#304121", }, canceled: { accent: "#7b716a", accentSoft: "rgba(123, 113, 106, 0.12)", muted: "#7b716a", buttonInk: "#433932", }, }; function isTaskLane(value) { return typeof value === "string" && TASK_LANES.includes(value); } function normalizeTag(raw) { const trimmed = String(raw || "") .trim() .replace(/^#+/, "") .replace(/\s+/g, "-"); return trimmed ? `#${trimmed}` : ""; } function normalizeTags(raw) { if (!Array.isArray(raw)) return []; const seen = new Set(); const tags = []; for (const value of raw) { const tag = normalizeTag(value); const key = tag.toLowerCase(); if (!tag || seen.has(key)) continue; seen.add(key); tags.push(tag); } return tags; } function normalizeMetadata(raw) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const entries = Object.entries(raw).filter(([, value]) => value !== undefined); return Object.fromEntries(entries); } function normalizeTask(raw, fallbackTitle) { const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {}; const lane = isTaskLane(record.lane) ? record.lane : "backlog"; return { taskPath: typeof record.task_path === "string" ? record.task_path.trim() : "", taskKey: typeof record.task_key === "string" ? record.task_key.trim() : "", title: typeof record.title === "string" && record.title.trim() ? record.title.trim() : fallbackTitle || "(Untitled task)", lane, created: typeof record.created === "string" ? record.created.trim() : "", updated: typeof record.updated === "string" ? record.updated.trim() : "", due: typeof record.due === "string" ? record.due.trim() : "", tags: normalizeTags(record.tags), body: typeof record.body === "string" ? record.body : "", metadata: normalizeMetadata(record.metadata), }; } function normalizeTaskFromPayload(raw, fallback) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fallback; return { taskPath: typeof raw.path === "string" ? raw.path.trim() : fallback.taskPath, taskKey: fallback.taskKey, title: typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : fallback.title, lane: isTaskLane(raw.lane) ? raw.lane : fallback.lane, created: typeof raw.created === "string" ? raw.created.trim() : fallback.created, updated: typeof raw.updated === "string" ? raw.updated.trim() : fallback.updated, due: typeof raw.due === "string" ? raw.due.trim() : fallback.due, tags: normalizeTags(raw.tags), body: typeof raw.body === "string" ? raw.body : fallback.body, metadata: normalizeMetadata(raw.metadata), }; } function parseToolPayload(result) { if (result && typeof result === "object" && result.parsed && typeof result.parsed === "object") { return result.parsed; } const raw = typeof result?.content === "string" ? result.content : ""; if (!raw.trim()) return null; try { const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; } catch { return null; } } function dueScore(hoursUntilDue) { if (hoursUntilDue <= 0) return 100; if (hoursUntilDue <= 6) return 96; if (hoursUntilDue <= 24) return 92; if (hoursUntilDue <= 72) return 82; if (hoursUntilDue <= 168) return 72; return 62; } function ageScore(ageDays) { if (ageDays >= 30) return 80; if (ageDays >= 21) return 76; if (ageDays >= 14) return 72; if (ageDays >= 7) return 68; if (ageDays >= 3) return 62; if (ageDays >= 1) return 58; return 54; } function computeTaskScore(task) { const now = Date.now(); const rawDue = task.due ? (task.due.includes("T") ? task.due : `${task.due}T12:00:00`) : ""; const dueMs = rawDue ? new Date(rawDue).getTime() : Number.NaN; let score = 54; if (Number.isFinite(dueMs)) { score = dueScore((dueMs - now) / (60 * 60 * 1000)); } else { const createdMs = task.created ? new Date(task.created).getTime() : Number.NaN; if (Number.isFinite(createdMs)) { score = ageScore(Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000))); } } if (task.lane === "committed") return Math.min(100, score + 1); if (task.lane === "blocked") return Math.min(100, score + 4); if (task.lane === "in-progress") return Math.min(100, score + 2); return score; } function summarizeTaskBody(task) { const trimmed = String(task.body || "").trim(); if (!trimmed || /^##\s+Imported\b/i.test(trimmed)) return ""; return trimmed; } function renderTaskBodyMarkdown(host, body) { if (!body) return ""; return body .replace(/\r\n?/g, "\n") .trim() .split("\n") .map((line) => { const trimmed = line.trim(); if (!trimmed) return ''; let className = "task-card-ui__md-line"; let content = trimmed; let prefix = ""; const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); if (headingMatch) { className += " task-card-ui__md-line--heading"; content = headingMatch[2]; } else if (/^[-*]\s+/.test(trimmed)) { className += " task-card-ui__md-line--bullet"; content = trimmed.replace(/^[-*]\s+/, ""); prefix = "\u2022 "; } else if (/^\d+\.\s+/.test(trimmed)) { className += " task-card-ui__md-line--bullet"; content = trimmed.replace(/^\d+\.\s+/, ""); prefix = "\u2022 "; } else if (/^>\s+/.test(trimmed)) { className += " task-card-ui__md-line--quote"; content = trimmed.replace(/^>\s+/, ""); prefix = "> "; } const html = host.renderMarkdown(content, { inline: true }); return `${ prefix ? `${prefix}` : "" }${html}`; }) .join(""); } function formatTaskDue(task) { if (!task.due) return ""; const raw = task.due.includes("T") ? task.due : `${task.due}T00:00:00`; const parsed = new Date(raw); if (Number.isNaN(parsed.getTime())) return task.due; if (task.due.includes("T")) { const label = parsed.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); return label.replace(/\s([AP]M)$/i, "$1"); } return parsed.toLocaleDateString([], { month: "short", day: "numeric" }); } function taskMoveOptions(lane) { return TASK_LANES.filter((targetLane) => targetLane !== lane).map((targetLane) => ({ lane: targetLane, label: TASK_ACTION_LABELS[targetLane], })); } function taskLiveContent(task, errorText) { return { kind: "file_task", exists: true, task_path: task.taskPath || null, task_key: task.taskKey || null, title: task.title || null, lane: task.lane, created: task.created || null, updated: task.updated || null, due: task.due || null, tags: task.tags, metadata: task.metadata, score: computeTaskScore(task), status: task.lane, error: errorText || null, }; } function autosizeEditor(editor) { editor.style.height = "0px"; editor.style.height = `${Math.max(editor.scrollHeight, 20)}px`; } export function mount({ root, item, state, host }) { const cardEl = root.querySelector(".task-card-ui"); const laneToggleEl = root.querySelector(".task-card-ui__lane-button"); const laneWrapEl = root.querySelector(".task-card-ui__lane-wrap"); const laneMenuEl = root.querySelector(".task-card-ui__lane-menu"); const statusEl = root.querySelector(".task-card-ui__status"); const titleEl = root.querySelector(".task-card-ui__title-slot"); const tagsEl = root.querySelector(".task-card-ui__tags"); const bodyEl = root.querySelector(".task-card-ui__body-slot"); const metaEl = root.querySelector(".task-card-ui__meta"); const dueEl = root.querySelector(".task-card-ui__chip"); if ( !(cardEl instanceof HTMLElement) || !(laneToggleEl instanceof HTMLButtonElement) || !(laneWrapEl instanceof HTMLElement) || !(laneMenuEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(titleEl instanceof HTMLElement) || !(tagsEl instanceof HTMLElement) || !(bodyEl instanceof HTMLElement) || !(metaEl instanceof HTMLElement) || !(dueEl instanceof HTMLElement) ) { return; } let task = normalizeTask(state, item.title); let busy = false; let errorText = ""; let statusLabel = ""; let statusKind = "neutral"; let laneMenuOpen = false; let editingField = null; let holdTimer = null; const clearHoldTimer = () => { if (holdTimer !== null) { window.clearTimeout(holdTimer); holdTimer = null; } }; const setStatus = (label, kind) => { statusLabel = label || ""; statusKind = kind || "neutral"; statusEl.textContent = statusLabel; statusEl.className = `task-card-ui__status${statusKind === "error" ? " is-error" : ""}`; }; const publishLiveContent = () => { host.setLiveContent(taskLiveContent(task, errorText)); }; const closeLaneMenu = () => { laneMenuOpen = false; laneToggleEl.setAttribute("aria-expanded", "false"); laneMenuEl.style.display = "none"; }; const openLaneMenu = () => { if (busy || !task.taskPath) return; laneMenuOpen = true; laneToggleEl.setAttribute("aria-expanded", "true"); laneMenuEl.style.display = "flex"; }; const refreshFeed = () => { closeLaneMenu(); host.requestFeedRefresh(); }; const setBusy = (nextBusy) => { busy = !!nextBusy; laneToggleEl.disabled = busy || !task.taskPath; titleEl.style.pointerEvents = busy ? "none" : ""; bodyEl.style.pointerEvents = busy ? "none" : ""; tagsEl .querySelectorAll("button") .forEach((button) => (button.disabled = busy || (!task.taskPath && button.dataset.role !== "noop"))); laneMenuEl.querySelectorAll("button").forEach((button) => { button.disabled = busy; }); }; const runBusyAction = async (action) => { setBusy(true); errorText = ""; setStatus("Saving", "neutral"); try { await action(); setStatus("", "neutral"); } catch (error) { console.error("Task card action failed", error); errorText = error instanceof Error ? error.message : String(error); setStatus("Unavailable", "error"); } finally { setBusy(false); render(); } }; const callTaskBoard = async (argumentsValue) => { const result = await host.callTool("task_board", argumentsValue); const payload = parseToolPayload(result); if (payload && typeof payload.error === "string" && payload.error.trim()) { throw new Error(payload.error); } return payload; }; const moveTask = async (lane) => runBusyAction(async () => { const payload = await callTaskBoard({ action: "move", task: task.taskPath, lane }); const nextTask = normalizeTaskFromPayload(payload?.task, { ...task, lane, taskPath: typeof payload?.task_path === "string" ? payload.task_path.trim() : task.taskPath, updated: new Date().toISOString(), }); task = nextTask; refreshFeed(); }); const editField = async (field, rawValue) => { const nextValue = rawValue.trim(); const currentValue = field === "title" ? task.title : task.body; if (field === "title" && !nextValue) return false; if (nextValue === currentValue) { editingField = null; render(); return true; } await runBusyAction(async () => { const payload = await callTaskBoard({ action: "edit", task: task.taskPath, ...(field === "title" ? { title: nextValue } : { description: nextValue }), }); task = normalizeTaskFromPayload(payload?.task, { ...task, ...(field === "title" ? { title: nextValue } : { body: nextValue }), }); editingField = null; refreshFeed(); }); return true; }; const addTag = async () => { const raw = window.prompt("Add tag to task", ""); const tag = raw == null ? "" : normalizeTag(raw); if (!tag) return; await runBusyAction(async () => { const payload = await callTaskBoard({ action: "add_tag", task: task.taskPath, tags: [tag], }); task = normalizeTaskFromPayload(payload?.task, { ...task, tags: Array.from(new Set([...task.tags, tag])), }); refreshFeed(); }); }; const removeTag = async (tag) => runBusyAction(async () => { const payload = await callTaskBoard({ action: "remove_tag", task: task.taskPath, tags: [tag], }); task = normalizeTaskFromPayload(payload?.task, { ...task, tags: task.tags.filter((value) => value !== tag), }); refreshFeed(); }); const beginTitleEdit = () => { if (!task.taskPath || busy || editingField) return; closeLaneMenu(); editingField = "title"; render(); }; const beginBodyEdit = () => { if (!task.taskPath || busy || editingField) return; closeLaneMenu(); editingField = "body"; render(); }; const renderInlineEditor = (targetEl, field, value, placeholder) => { targetEl.innerHTML = ""; const editor = document.createElement("textarea"); editor.className = `${field === "title" ? "task-card-ui__title" : "task-card-ui__body"} task-card-ui__editor`; editor.rows = field === "title" ? 1 : Math.max(1, value.split("\n").length); editor.value = value; if (placeholder) editor.placeholder = placeholder; editor.disabled = busy; targetEl.appendChild(editor); autosizeEditor(editor); const cancel = () => { editingField = null; render(); }; editor.addEventListener("input", () => { autosizeEditor(editor); }); editor.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.preventDefault(); cancel(); return; } if (field === "title" && event.key === "Enter" && !event.shiftKey) { event.preventDefault(); editor.blur(); return; } if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { event.preventDefault(); editor.blur(); } }); editor.addEventListener("blur", () => { if (editingField !== field) return; void editField(field, editor.value); }); requestAnimationFrame(() => { editor.focus(); const end = editor.value.length; editor.setSelectionRange(end, end); }); }; const renderTags = () => { tagsEl.innerHTML = ""; task.tags.forEach((tag) => { const button = document.createElement("button"); button.className = "task-card-ui__tag"; button.type = "button"; button.textContent = tag; button.title = `Hold to remove ${tag}`; button.disabled = busy; button.addEventListener("pointerdown", (event) => { if (event.pointerType === "mouse" && event.button !== 0) return; clearHoldTimer(); button.classList.add("is-holding"); holdTimer = window.setTimeout(() => { holdTimer = null; button.classList.remove("is-holding"); if (window.confirm(`Remove ${tag} from this task?`)) { void removeTag(tag); } }, 650); }); ["pointerup", "pointerleave", "pointercancel"].forEach((eventName) => { button.addEventListener(eventName, () => { clearHoldTimer(); button.classList.remove("is-holding"); }); }); button.addEventListener("contextmenu", (event) => { event.preventDefault(); }); button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); }); tagsEl.appendChild(button); }); const addButton = document.createElement("button"); addButton.className = "task-card-ui__tag task-card-ui__tag--action"; addButton.type = "button"; addButton.textContent = "+"; addButton.title = "Add tag"; addButton.disabled = busy || !task.taskPath; addButton.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); void addTag(); }); tagsEl.appendChild(addButton); }; const renderLaneMenu = () => { laneMenuEl.innerHTML = ""; if (!task.taskPath) { closeLaneMenu(); return; } taskMoveOptions(task.lane).forEach((option) => { const button = document.createElement("button"); button.className = "task-card-ui__lane-menu-item"; button.type = "button"; button.textContent = option.label; button.disabled = busy; button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); void moveTask(option.lane); }); laneMenuEl.appendChild(button); }); laneMenuEl.style.display = laneMenuOpen ? "flex" : "none"; }; const applyTheme = () => { const theme = TASK_LANE_THEMES[task.lane] || TASK_LANE_THEMES.backlog; cardEl.style.setProperty("--task-accent", theme.accent); cardEl.style.setProperty("--task-accent-soft", theme.accentSoft); cardEl.style.setProperty("--task-muted", theme.muted); cardEl.style.setProperty("--task-button-ink", theme.buttonInk); }; const render = () => { applyTheme(); laneToggleEl.textContent = ""; const laneLabelEl = document.createElement("span"); laneLabelEl.className = "task-card-ui__lane"; laneLabelEl.textContent = TASK_LANE_LABELS[task.lane] || "Task"; const caretEl = document.createElement("span"); caretEl.className = `task-card-ui__lane-caret${laneMenuOpen ? " open" : ""}`; caretEl.textContent = "▾"; laneToggleEl.append(laneLabelEl, caretEl); laneToggleEl.disabled = busy || !task.taskPath; laneToggleEl.setAttribute("aria-expanded", laneMenuOpen ? "true" : "false"); setStatus(statusLabel, statusKind); if (editingField === "title") { renderInlineEditor(titleEl, "title", task.title, ""); } else { titleEl.innerHTML = ""; const button = document.createElement("button"); button.className = "task-card-ui__title task-card-ui__text-button"; button.type = "button"; button.disabled = busy || !task.taskPath; button.textContent = task.title || "(Untitled task)"; button.addEventListener("click", beginTitleEdit); titleEl.appendChild(button); } const bodySummary = summarizeTaskBody(task); if (editingField === "body") { renderInlineEditor(bodyEl, "body", task.body, "Add description"); } else { bodyEl.innerHTML = ""; const button = document.createElement("button"); button.className = `task-card-ui__body task-card-ui__text-button task-card-ui__body-markdown${ bodySummary ? "" : " is-placeholder" }`; button.type = "button"; button.disabled = busy || !task.taskPath; const inner = document.createElement("span"); inner.className = "task-card-ui__body-markdown-inner"; inner.innerHTML = bodySummary ? renderTaskBodyMarkdown(host, bodySummary) : "Add description"; button.appendChild(inner); button.addEventListener("click", beginBodyEdit); bodyEl.appendChild(button); } renderTags(); renderLaneMenu(); const dueText = formatTaskDue(task); dueEl.textContent = dueText; metaEl.style.display = dueText ? "flex" : "none"; publishLiveContent(); setBusy(busy); }; const handleLaneToggle = (event) => { event.preventDefault(); event.stopPropagation(); if (laneMenuOpen) closeLaneMenu(); else openLaneMenu(); render(); }; const handleDocumentPointerDown = (event) => { if (!laneMenuOpen) return; if (!(event.target instanceof Node)) return; if (laneWrapEl.contains(event.target)) return; closeLaneMenu(); render(); }; const handleEscape = (event) => { if (event.key !== "Escape" || !laneMenuOpen) return; closeLaneMenu(); render(); }; laneToggleEl.addEventListener("click", handleLaneToggle); document.addEventListener("pointerdown", handleDocumentPointerDown); document.addEventListener("keydown", handleEscape); render(); return { update({ item: nextItem, state: nextState }) { task = normalizeTask(nextState, nextItem.title); errorText = ""; statusLabel = ""; statusKind = "neutral"; laneMenuOpen = false; editingField = null; render(); }, destroy() { clearHoldTimer(); document.removeEventListener("pointerdown", handleDocumentPointerDown); document.removeEventListener("keydown", handleEscape); laneToggleEl.removeEventListener("click", handleLaneToggle); host.setRefreshHandler(null); host.setLiveContent(null); host.clearSelection(); }, }; }