From b7c80fc010910fc9e96fe75bf3103e2c98f20337 Mon Sep 17 00:00:00 2001 From: Chetan Nandakumar Date: Mon, 16 Mar 2026 08:29:12 -0700 Subject: [PATCH] feat: add built-in Kanban board with agent auto-tracking Adds a three-column Kanban board (To Do / In Progress / Done) directly into the ClaudeClaw web UI with no external server, iframe, or hardcoded localhost URLs. Board state persists to .claude/claudeclaw/kanban.json via new GET/POST /api/kanban endpoints. Includes tab navigation between Dashboard and Kanban views, an add-task modal, clear-done action, and 10-second polling for live agent-driven updates. --- src/ui/page/script.ts | 134 ++++++++++++++++++++++- src/ui/page/styles.ts | 224 +++++++++++++++++++++++++++++++++++++- src/ui/page/template.ts | 61 +++++++++++ src/ui/server.ts | 15 +++ src/ui/services/kanban.ts | 77 +++++++++++++ 5 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 src/ui/services/kanban.ts diff --git a/src/ui/page/script.ts b/src/ui/page/script.ts index b6c433c3..64883c5e 100644 --- a/src/ui/page/script.ts +++ b/src/ui/page/script.ts @@ -930,4 +930,136 @@ export const pageScript = String.raw` const $ = (id) => document.getElementBy loadSettings(); refreshState(); - setInterval(refreshState, 1000);`; + setInterval(refreshState, 1000); + +// ── Kanban ─────────────────────────────────────────────────────────────────── +var tabDashboardBtn = $("tab-dashboard-btn"); +var tabKanbanBtn = $("tab-kanban-btn"); +var dashboardPanel = $("dashboard-panel"); +var kanbanPanel = $("kanban-panel"); + +function setActiveTab(tab) { + var isDash = tab === "dashboard"; + if (dashboardPanel) dashboardPanel.hidden = !isDash; + if (kanbanPanel) kanbanPanel.hidden = isDash; + if (tabDashboardBtn) tabDashboardBtn.classList.toggle("tab-btn-active", isDash); + if (tabKanbanBtn) tabKanbanBtn.classList.toggle("tab-btn-active", !isDash); +} + +if (tabDashboardBtn) tabDashboardBtn.addEventListener("click", function() { setActiveTab("dashboard"); }); +if (tabKanbanBtn) tabKanbanBtn.addEventListener("click", function() { setActiveTab("kanban"); }); + +var kanbanData = { columns: { todo: [], in_progress: [], done: [] } }; + +function timeAgoKanban(isoString) { + if (!isoString) return ""; + var diff = Date.now() - new Date(isoString).getTime(); + var s = Math.floor(diff / 1000); + if (s < 60) return s + "s ago"; + var m = Math.floor(s / 60); + if (m < 60) return m + "m ago"; + var h = Math.floor(m / 60); + if (h < 24) return h + "h ago"; + return Math.floor(h / 24) + "d ago"; +} + +function escKanban(str) { + if (!str) return ""; + return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function renderKanbanCard(card, isDone) { + return '
' + + '
' + escKanban(card.title) + "
" + + (card.description ? '
' + escKanban(card.description) + "
" : "") + + '
' + + (card.started_at ? '' + timeAgoKanban(isDone ? card.completed_at : card.started_at) + "" : "") + + "
" + + "
"; +} + +function renderKanban() { + var todo = kanbanData.columns.todo || []; + var ip = kanbanData.columns.in_progress || []; + var done = kanbanData.columns.done || []; + + var countTodo = $("kanban-count-todo"); + var countIp = $("kanban-count-inprogress"); + var countDone = $("kanban-count-done"); + var cardsTodo = $("kanban-cards-todo"); + var cardsIp = $("kanban-cards-inprogress"); + var cardsDone = $("kanban-cards-done"); + + if (countTodo) countTodo.textContent = todo.length; + if (countIp) countIp.textContent = ip.length; + if (countDone) countDone.textContent = done.length; + + if (cardsTodo) cardsTodo.innerHTML = todo.length + ? todo.map(function(c) { return renderKanbanCard(c, false); }).join("") + : '
No tasks queued
'; + if (cardsIp) cardsIp.innerHTML = ip.length + ? ip.map(function(c) { return renderKanbanCard(c, false); }).join("") + : '
No active tasks
'; + if (cardsDone) cardsDone.innerHTML = done.length + ? done.map(function(c) { return renderKanbanCard(c, true); }).join("") + : '
Nothing completed yet
'; +} + +async function loadKanban() { + try { + var res = await fetch("/api/kanban"); + if (res.ok) { kanbanData = await res.json(); renderKanban(); } + } catch (_) {} +} + +async function saveKanban() { + try { + await fetch("/api/kanban", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(kanbanData) }); + } catch (_) {} +} + +function clearKanbanDone() { + kanbanData.columns.done = []; + renderKanban(); + saveKanban(); +} + +// Modal +var kanbanModal = $("kanban-modal-overlay"); +var kanbanAddBtn = $("kanban-add-btn"); +var kanbanCancelBtn = $("kanban-cancel-btn"); +var kanbanSaveBtn = $("kanban-save-btn"); +var kanbanModalClose = $("kanban-modal-close"); + +function openKanbanModal() { if (kanbanModal) kanbanModal.hidden = false; } +function closeKanbanModal() { + if (kanbanModal) kanbanModal.hidden = true; + var t = $("kanban-input-title"); var d = $("kanban-input-desc"); + if (t) t.value = ""; if (d) d.value = ""; +} + +function addKanbanTask() { + var titleEl = $("kanban-input-title"); + var descEl = $("kanban-input-desc"); + var title = titleEl ? titleEl.value.trim() : ""; + if (!title) { if (titleEl) titleEl.focus(); return; } + var card = { id: "task-" + Date.now(), title: title, description: descEl ? descEl.value.trim() : "", started_at: new Date().toISOString() }; + if (!Array.isArray(kanbanData.columns.todo)) kanbanData.columns.todo = []; + kanbanData.columns.todo.unshift(card); + closeKanbanModal(); + renderKanban(); + saveKanban(); +} + +if (kanbanAddBtn) kanbanAddBtn.addEventListener("click", openKanbanModal); +if (kanbanCancelBtn) kanbanCancelBtn.addEventListener("click", closeKanbanModal); +if (kanbanModalClose) kanbanModalClose.addEventListener("click", closeKanbanModal); +if (kanbanSaveBtn) kanbanSaveBtn.addEventListener("click", addKanbanTask); +if ($("kanban-input-title")) { + $("kanban-input-title").addEventListener("keydown", function(e) { + if (e.key === "Enter") { e.preventDefault(); addKanbanTask(); } + }); +} + +loadKanban(); +setInterval(loadKanban, 10000);`; diff --git a/src/ui/page/styles.ts b/src/ui/page/styles.ts index 9fe63493..21dba1d5 100644 --- a/src/ui/page/styles.ts +++ b/src/ui/page/styles.ts @@ -1130,4 +1130,226 @@ export const pageStyles = String.raw` :root { .pill:nth-last-child(2) { border-bottom: 0; } - }`; + } + +/* ── Tab nav ── */ +[hidden] { display: none !important; } +.tab-nav { + display: flex; + gap: 4px; + padding: 12px 24px 0; + border-bottom: 1px solid var(--glass-border, rgba(255,255,255,0.08)); + margin-bottom: 0; +} +.tab-btn { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--fg-3, #888); + cursor: pointer; + font-family: inherit; + font-size: 13px; + font-weight: 500; + letter-spacing: 0.03em; + padding: 6px 14px 10px; + transition: color 0.15s, border-color 0.15s; +} +.tab-btn:hover { color: var(--fg-1, #eee); } +.tab-btn-active { + border-bottom-color: var(--accent, #4a9eff); + color: var(--fg-1, #eee); +} + +/* ── Kanban board ── */ +.kanban-board { + display: flex; + gap: 0; + height: calc(100vh - 200px); + overflow: hidden; +} +.kanban-col { + flex: 1; + display: flex; + flex-direction: column; + border-right: 1px solid rgba(255,255,255,0.06); + overflow: hidden; +} +.kanban-col:last-child { border-right: none; } +.kanban-col-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px 10px; + flex-shrink: 0; +} +.kanban-col-title-group { + display: flex; + align-items: center; + gap: 8px; +} +.kanban-col-indicator { + width: 7px; + height: 7px; + border-radius: 50%; +} +.kanban-indicator-todo { background: #a855f7; } +.kanban-indicator-inprogress { background: #f59e0b; box-shadow: 0 0 6px #f59e0b; } +.kanban-indicator-done { background: #22c55e; } +.kanban-col-title { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: rgba(255,255,255,0.5); +} +.kanban-col-count { + font-size: 11px; + font-weight: 600; + background: rgba(255,255,255,0.08); + border-radius: 10px; + padding: 1px 7px; + color: rgba(255,255,255,0.4); +} +.kanban-clear-btn { + background: none; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 5px; + color: rgba(255,255,255,0.35); + cursor: pointer; + font-size: 11px; + padding: 2px 8px; +} +.kanban-clear-btn:hover { color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.25); } +.kanban-cards { + flex: 1; + overflow-y: auto; + padding: 8px 14px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} +.kanban-card { + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + padding: 10px 12px; + cursor: default; + transition: background 0.15s; +} +.kanban-card:hover { background: rgba(255,255,255,0.07); } +.kanban-card-title { + font-size: 13px; + font-weight: 500; + color: rgba(255,255,255,0.85); + margin-bottom: 3px; +} +.kanban-card-desc { + font-size: 12px; + color: rgba(255,255,255,0.4); + margin-bottom: 5px; + line-height: 1.4; +} +.kanban-card-meta { + font-size: 11px; + color: rgba(255,255,255,0.3); + font-family: "JetBrains Mono", monospace; +} +.kanban-empty { + color: rgba(255,255,255,0.2); + font-size: 12px; + text-align: center; + padding: 24px 0; +} +.kanban-toolbar { + padding: 10px 18px; + border-top: 1px solid rgba(255,255,255,0.06); +} +.kanban-add-btn { + background: rgba(74,158,255,0.1); + border: 1px solid rgba(74,158,255,0.25); + border-radius: 7px; + color: rgba(74,158,255,0.9); + cursor: pointer; + font-size: 13px; + font-weight: 500; + padding: 7px 16px; + transition: background 0.15s; +} +.kanban-add-btn:hover { background: rgba(74,158,255,0.18); } +/* Kanban modal */ +.kanban-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} +.kanban-modal { + background: #1a1a1f; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; + min-width: 360px; + max-width: 480px; + width: 100%; +} +.kanban-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 12px; + font-weight: 600; + font-size: 14px; + border-bottom: 1px solid rgba(255,255,255,0.07); +} +.kanban-modal-close { + background: none; + border: none; + color: rgba(255,255,255,0.4); + cursor: pointer; + font-size: 20px; + line-height: 1; +} +.kanban-modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; } +.kanban-input { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 7px; + color: rgba(255,255,255,0.85); + font-family: inherit; + font-size: 13px; + padding: 9px 12px; + width: 100%; + resize: none; +} +.kanban-input:focus { outline: none; border-color: rgba(74,158,255,0.4); } +.kanban-textarea { min-height: 70px; } +.kanban-modal-footer { + display: flex; + gap: 8px; + justify-content: flex-end; + padding: 12px 20px 16px; + border-top: 1px solid rgba(255,255,255,0.07); +} +.kanban-btn-primary { + background: rgba(74,158,255,0.15); + border: 1px solid rgba(74,158,255,0.3); + border-radius: 7px; + color: rgba(74,158,255,0.95); + cursor: pointer; + font-size: 13px; + font-weight: 500; + padding: 7px 16px; +} +.kanban-btn-primary:hover { background: rgba(74,158,255,0.25); } +.kanban-btn-secondary { + background: none; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 7px; + color: rgba(255,255,255,0.4); + cursor: pointer; + font-size: 13px; + padding: 7px 16px; +} +.kanban-btn-secondary:hover { color: rgba(255,255,255,0.7); }`; diff --git a/src/ui/page/template.ts b/src/ui/page/template.ts index eceaf216..0ef4505a 100644 --- a/src/ui/page/template.ts +++ b/src/ui/page/template.ts @@ -111,6 +111,11 @@ ${pageStyles} + +
+
+