feat: enrich kanban cards, editable drawer, debounced live updates (fixes #170)

Gap 1: card DTO now carries tenant, created_at, comment_count and child
progress; cardElement renders dense chrome (checkbox, status dot, id,
priority badge, tenant chip, assignee, age, comment count, progress pill).

Gap 2: new POST /api/kanban/tasks/{id}/comments route plus add_comment on
the store protocol; drawer is now editable (title/assignee/priority),
markdown-rendered (XSS-safe), shows a comment thread with Enter-to-submit
and run/event history rows.

Gap 3: SSE consumer refetches on a 300ms debounce, the open drawer
live-refreshes on events for its task, and destructive moves/delete prompt
for confirmation.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
MervinPraisonandClaude committed 2026-06-28 04:23:35 +00:00
1 parent 0b62e7dcec
commit 9726411010
4 files changed
+483 -48

No files matched your search

+65 -2
View File
@@ -46,10 +46,33 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _child_progress(task: Dict[str, Any]) -> Optional[Dict[str, int]]:
"""Derive done/total subtask progress from task.children or meta.
Accepts either a list of child dicts under "children" (each with a status)
or an explicit {"done": int, "total": int} under meta["progress"].
Returns None when there is nothing to report.
"""
meta = task.get("meta") or {}
explicit = meta.get("progress")
if isinstance(explicit, dict):
total = explicit.get("total")
done = explicit.get("done")
if isinstance(total, int) and total > 0 and isinstance(done, int):
return {"done": max(0, min(done, total)), "total": total}
children = task.get("children")
if isinstance(children, list) and children:
total = len(children)
done = sum(1 for c in children if isinstance(c, dict) and c.get("status") == "done")
return {"done": done, "total": total}
return None
def _task_card(task: Dict[str, Any]) -> Dict[str, Any]:
"""Normalise task dict to board card component."""
title = (task.get("title") or task.get("id") or "Task")[:120]
footer_parts = [p for p in (task.get("assignee"), task.get("priority")) if p]
comments = task.get("comments") or []
return {
"id": task.get("id"),
"title": title,
@@ -57,8 +80,12 @@ def _task_card(task: Dict[str, Any]) -> Dict[str, Any]:
"status": task.get("status"),
"assignee": task.get("assignee"),
"priority": task.get("priority"),
"tenant": task.get("tenant"),
"created_at": task.get("created_at"),
"comment_count": len(comments),
"progress": _child_progress(task),
"body": task.get("body"),
"comments": task.get("comments") or [],
"comments": comments,
"meta": task.get("meta") or {},
}
@@ -90,6 +117,9 @@ class KanbanStoreProtocol(ABC):
@abstractmethod
def move_task(self, task_id: str, status: str) -> Optional[Dict[str, Any]]: ...
@abstractmethod
def add_comment(self, task_id: str, comment: Dict[str, Any]) -> Optional[Dict[str, Any]]: ...
@abstractmethod
def bulk_update(self, task_ids: List[str], status: str) -> Dict[str, Any]: ...
@@ -174,6 +204,7 @@ class SimpleKanbanStore(KanbanStoreProtocol):
"priority": data.get("priority"),
"tenant": data.get("tenant"),
"board": data.get("board", "default"),
"children": data.get("children") or [],
"comments": [],
"meta": data.get("meta") or {},
"created_at": _now_iso(),
@@ -188,7 +219,7 @@ class SimpleKanbanStore(KanbanStoreProtocol):
task = self._tasks.get(task_id)
if not task:
return None
for key in ("title", "body", "assignee", "priority", "tenant", "meta"):
for key in ("title", "body", "assignee", "priority", "tenant", "meta", "children"):
if key in data:
task[key] = data[key]
if "status" in data and data["status"] in VALID_STATUSES:
@@ -209,6 +240,24 @@ class SimpleKanbanStore(KanbanStoreProtocol):
self._emit("task_moved", task, {"from_status": old, "to_status": status})
return dict(task)
def add_comment(self, task_id: str, comment: Dict[str, Any]) -> Optional[Dict[str, Any]]:
task = self._tasks.get(task_id)
if not task:
return None
text = str((comment or {}).get("text") or "").strip()
if not text:
return None
entry = {
"id": f"c_{uuid.uuid4().hex[:8]}",
"author": (comment or {}).get("author") or "human",
"text": text,
"created_at": _now_iso(),
}
task.setdefault("comments", []).append(entry)
task["updated_at"] = _now_iso()
self._emit("task_commented", task, {"comment_id": entry["id"]})
return dict(task)
def bulk_update(self, task_ids: List[str], status: str) -> Dict[str, Any]:
if status not in VALID_STATUSES:
return {"updated": 0, "errors": ["invalid status"]}
@@ -264,6 +313,9 @@ class InjectedKanbanStore(KanbanStoreProtocol):
def move_task(self, task_id: str, status: str) -> Optional[Dict[str, Any]]:
return self._call("move_task", task_id, status)
def add_comment(self, task_id: str, comment: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return self._call("add_comment", task_id, comment)
def bulk_update(self, task_ids: List[str], status: str) -> Dict[str, Any]:
return self._call("bulk_update", task_ids, status)
@@ -331,6 +383,7 @@ class KanbanFeature(BaseFeatureProtocol):
Route("/api/kanban/tasks/{task_id}", self._patch, methods=["PATCH"]),
Route("/api/kanban/tasks/{task_id}", self._delete, methods=["DELETE"]),
Route("/api/kanban/tasks/{task_id}/move", self._move, methods=["POST"]),
Route("/api/kanban/tasks/{task_id}/comments", self._add_comment, methods=["POST"]),
Route("/api/kanban/events", self._events, methods=["GET"]),
Route("/api/kanban/health", self._health, methods=["GET"]),
Route("/api/kanban/boards", self._boards, methods=["GET"]),
@@ -380,6 +433,16 @@ class KanbanFeature(BaseFeatureProtocol):
return JSONResponse({"error": "not found or invalid status"}, status_code=404)
return JSONResponse(task)
async def _add_comment(self, request: Request) -> JSONResponse:
store = get_kanban_store()
body = await request.json()
if not isinstance(body, dict) or not str(body.get("text") or "").strip():
return JSONResponse({"error": "text required"}, status_code=400)
task = store.add_comment(request.path_params["task_id"], body)
if not task:
return JSONResponse({"error": "not found"}, status_code=404)
return JSONResponse(task)
async def _bulk(self, request: Request) -> JSONResponse:
store = get_kanban_store()
body = await request.json()
@@ -35,6 +35,60 @@ function el(tag, attrs, children) {
return node;
}
function relativeTime(value) {
if (!value) return '';
const then = typeof value === 'number' ? value * 1000 : Date.parse(value);
if (!then || Number.isNaN(then)) return '';
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function escapeHtml(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderMarkdown(text) {
const safe = escapeHtml(text);
const lines = safe.split(/\r?\n/);
const html = [];
let inCode = false;
let listOpen = false;
const closeList = () => { if (listOpen) { html.push('</ul>'); listOpen = false; } };
lines.forEach((raw) => {
if (/^```/.test(raw.trim())) {
if (inCode) { html.push('</code></pre>'); inCode = false; }
else { closeList(); html.push('<pre><code>'); inCode = true; }
return;
}
if (inCode) { html.push(raw + '\n'); return; }
let line = raw;
line = line.replace(/`([^`]+)`/g, '<code>$1</code>');
line = line.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
line = line.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
const heading = line.match(/^(#{1,3})\s+(.*)$/);
if (heading) { closeList(); html.push(`<h${heading[1].length}>${heading[2]}</h${heading[1].length}>`); return; }
const bullet = line.match(/^\s*[-*]\s+(.*)$/);
if (bullet) { if (!listOpen) { html.push('<ul>'); listOpen = true; } html.push(`<li>${bullet[1]}</li>`); return; }
closeList();
if (line.trim() === '') html.push('<br/>');
else html.push(`<p>${line}</p>`);
});
closeList();
if (inCode) html.push('</code></pre>');
return html.join('');
}
function cardElement(card, { selectable, selected, onSelect, onOpen }) {
const wrap = el('div', {
className: `aiui-board-card${selected ? ' aiui-board-card-selected' : ''}`,
@@ -42,18 +96,48 @@ function cardElement(card, { selectable, selected, onSelect, onOpen }) {
'data-task-id': card.id || '',
'data-status': card.status || '',
});
const inner = el('div', { className: 'db-card' });
if (card.title) inner.appendChild(el('div', { className: 'db-card-title', text: card.title }));
if (card.value != null) inner.appendChild(el('div', { className: 'db-card-value', text: String(card.value) }));
const footer = card.footer || card.assignee || '';
if (footer) inner.appendChild(el('div', { className: 'db-card-footer', text: footer }));
const inner = el('div', { className: 'db-card aiui-board-card-dense' });
const head = el('div', { className: 'aiui-board-card-head' });
if (selectable && onSelect) {
const box = el('input', {
type: 'checkbox',
className: 'aiui-board-card-check',
'aria-label': 'Select task',
});
box.checked = !!selected;
box.addEventListener('click', (e) => { e.stopPropagation(); onSelect(card.id, true); });
head.appendChild(box);
}
if (card.id) head.appendChild(el('span', { className: 'aiui-board-card-id', text: card.id }));
const colour = STATUS_COLOURS[card.status];
if (card.status) {
const dot = el('span', { className: 'aiui-board-status-dot', text: '●', title: card.status });
if (colour) dot.style.color = colour;
head.appendChild(dot);
}
if (card.priority) {
inner.appendChild(el('span', {
className: 'aiui-board-badge',
text: String(card.priority),
style: 'font-size:10px;margin-left:6px;opacity:0.8',
head.appendChild(el('span', { className: 'aiui-board-badge aiui-board-priority', text: String(card.priority) }));
}
inner.appendChild(head);
if (card.title) inner.appendChild(el('div', { className: 'db-card-title aiui-board-card-name', text: card.title }));
if (card.value != null) inner.appendChild(el('div', { className: 'db-card-value', text: String(card.value) }));
const foot = el('div', { className: 'aiui-board-card-foot' });
if (card.tenant) foot.appendChild(el('span', { className: 'aiui-board-chip aiui-board-tenant', text: String(card.tenant) }));
if (card.assignee) foot.appendChild(el('span', { className: 'aiui-board-chip', text: String(card.assignee) }));
const age = relativeTime(card.created_at);
if (age) foot.appendChild(el('span', { className: 'aiui-board-card-age', text: age }));
if (card.comment_count) foot.appendChild(el('span', { className: 'aiui-board-card-count', text: `\uD83D\uDCAC ${card.comment_count}` }));
if (card.progress && card.progress.total) {
foot.appendChild(el('span', {
className: 'aiui-board-progress',
text: `${card.progress.done}/${card.progress.total}`,
}));
}
if (foot.childNodes.length) inner.appendChild(foot);
wrap.appendChild(inner);
wrap.addEventListener('click', (e) => {
if (e.shiftKey && onSelect) onSelect(card.id, true);
@@ -148,7 +232,9 @@ function renderBoardDOM(container, data, opts) {
e.preventDefault();
trash.classList.remove('aiui-board-drop-target');
const taskId = e.dataTransfer.getData('text/plain');
if (taskId && opts.onDelete) await opts.onDelete(taskId);
if (!taskId || !opts.onDelete) return;
if (typeof confirm === 'function' && !confirm('Permanently delete this task?')) return;
await opts.onDelete(taskId);
});
wrap.appendChild(trash);
}
@@ -156,46 +242,170 @@ function renderBoardDOM(container, data, opts) {
return wrap;
}
function openTaskDrawer(card, { apiBase, onClose, onRefresh }) {
function historyRows(card) {
const meta = card.meta || {};
const rows = [];
const events = meta.events || meta.history;
if (Array.isArray(events)) {
events.forEach((ev) => {
if (typeof ev === 'string') rows.push({ text: ev });
else if (ev && typeof ev === 'object') {
const label = ev.type || ev.event || ev.status || 'event';
const when = relativeTime(ev.ts || ev.created_at || ev.time);
rows.push({ text: ev.text || ev.message || label, when });
}
});
}
return rows;
}
function openTaskDrawer(card, { apiBase, onClose, onRefresh, fetchTask }) {
const overlay = el('div', { className: 'aiui-board-drawer-overlay' });
const drawer = el('div', { className: 'aiui-board-drawer' });
const close = () => overlay.remove();
drawer.appendChild(el('button', {
className: 'aiui-board-drawer-close',
text: '×',
onclick: close,
}));
drawer.appendChild(el('h3', { text: card.title || 'Task' }));
if (card.body) drawer.appendChild(el('pre', { className: 'aiui-board-drawer-body', text: card.body }));
else drawer.appendChild(el('p', { className: 'aiui-board-drawer-meta', text: `ID: ${card.id || '—'}` }));
const meta = el('div', { className: 'aiui-board-drawer-meta' });
meta.textContent = [card.status, card.assignee, card.priority].filter(Boolean).join(' · ');
drawer.appendChild(meta);
let current = card;
let closed = false;
const close = () => { closed = true; overlay.remove(); if (onClose) onClose(); };
if (apiBase && card.id) {
const actions = el('div', { className: 'aiui-board-drawer-actions' });
['blocked', 'ready', 'done'].forEach((st) => {
actions.appendChild(el('button', {
className: 'db-btn db-btn-sm',
text: st,
onclick: async () => {
await fetch(`${apiBase}/tasks/${card.id}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: st }),
});
if (onRefresh) await onRefresh();
close();
if (onClose) onClose();
},
}));
const editable = !!(apiBase && card.id);
async function patch(data) {
if (!editable) return;
await fetch(`${apiBase}/tasks/${current.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
drawer.appendChild(actions);
if (onRefresh) await onRefresh();
await reload();
}
async function reload() {
if (closed) return;
if (fetchTask && current.id) {
try {
const fresh = await fetchTask(current.id);
if (fresh) current = { ...current, ...fresh };
} catch (_) { /* keep stale */ }
}
render();
}
function field(label, value, onSave) {
const row = el('div', { className: 'aiui-board-drawer-field' });
row.appendChild(el('label', { className: 'aiui-board-drawer-label', text: label }));
const input = el('input', { className: 'aiui-board-drawer-input', value: value == null ? '' : String(value) });
if (!editable) input.disabled = true;
input.addEventListener('change', () => onSave(input.value));
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); onSave(input.value); } });
row.appendChild(input);
return row;
}
function render() {
drawer.innerHTML = '';
drawer.appendChild(el('button', { className: 'aiui-board-drawer-close', text: '×', onclick: close }));
drawer.appendChild(field('Title', current.title, (v) => patch({ title: v })));
if (current.body != null) {
const bodyEl = el('div', { className: 'aiui-board-drawer-body aiui-board-markdown' });
bodyEl.innerHTML = renderMarkdown(current.body);
drawer.appendChild(bodyEl);
}
const grid = el('div', { className: 'aiui-board-drawer-grid' });
grid.appendChild(field('Assignee', current.assignee, (v) => patch({ assignee: v })));
grid.appendChild(field('Priority', current.priority, (v) => patch({ priority: v })));
drawer.appendChild(grid);
const meta = el('div', { className: 'aiui-board-drawer-meta' });
meta.textContent = [current.status, current.tenant, relativeTime(current.created_at)].filter(Boolean).join(' · ');
drawer.appendChild(meta);
const hist = historyRows(current);
if (hist.length) {
drawer.appendChild(el('h4', { className: 'aiui-board-drawer-h', text: 'History' }));
const histWrap = el('div', { className: 'aiui-board-history' });
hist.forEach((r) => {
const row = el('div', { className: 'aiui-board-history-row' });
row.appendChild(el('span', { className: 'aiui-board-history-text', text: r.text }));
if (r.when) row.appendChild(el('span', { className: 'aiui-board-history-when', text: r.when }));
histWrap.appendChild(row);
});
drawer.appendChild(histWrap);
}
drawer.appendChild(el('h4', { className: 'aiui-board-drawer-h', text: 'Comments' }));
const thread = el('div', { className: 'aiui-board-comments' });
(current.comments || []).forEach((c) => {
const item = el('div', { className: 'aiui-board-comment' });
const head = el('div', { className: 'aiui-board-comment-head' });
head.appendChild(el('span', { className: 'aiui-board-comment-author', text: c.author || 'human' }));
const when = relativeTime(c.created_at);
if (when) head.appendChild(el('span', { className: 'aiui-board-comment-when', text: when }));
item.appendChild(head);
const text = el('div', { className: 'aiui-board-comment-text aiui-board-markdown' });
text.innerHTML = renderMarkdown(typeof c === 'string' ? c : (c.text || ''));
item.appendChild(text);
thread.appendChild(item);
});
drawer.appendChild(thread);
if (editable) {
const box = el('textarea', {
className: 'aiui-board-comment-input',
placeholder: 'Add a comment… (Enter to submit, Shift+Enter for newline)',
rows: '2',
});
const submit = async () => {
const value = box.value.trim();
if (!value) return;
box.value = '';
await fetch(`${apiBase}/tasks/${current.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: value }),
});
if (onRefresh) await onRefresh();
await reload();
};
box.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
});
drawer.appendChild(box);
const actions = el('div', { className: 'aiui-board-drawer-actions' });
['blocked', 'ready', 'done'].forEach((st) => {
actions.appendChild(el('button', {
className: 'db-btn db-btn-sm',
text: st,
onclick: async () => {
if ((st === 'done' || st === 'blocked') && !confirm(`Move task to "${st}"?`)) return;
await fetch(`${apiBase}/tasks/${current.id}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: st }),
});
if (onRefresh) await onRefresh();
close();
},
}));
});
drawer.appendChild(actions);
}
}
render();
overlay.appendChild(drawer);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.body.appendChild(overlay);
return {
id: card.id,
isOpen: () => !closed,
refresh: reload,
close,
};
}
/**
@@ -205,8 +415,11 @@ export function createInteractiveBoard(root, opts = {}) {
let destroyed = false;
let pollTimer = null;
let eventSource = null;
let debounceTimer = null;
let openDrawer = null;
const selected = new Set();
let lastData = null;
const debounceMs = opts.debounceMs != null ? opts.debounceMs : 300;
const toolbar = el('div', { className: 'aiui-board-toolbar' });
root.appendChild(toolbar);
@@ -231,6 +444,14 @@ export function createInteractiveBoard(root, opts = {}) {
if (!res.ok) throw new Error(await res.text());
}
const drawerApiBase = (opts.drawerApiBase || apiBase).replace(/\/$/, '');
async function fetchTask(taskId) {
const res = await fetch(`${drawerApiBase}/tasks/${taskId}`);
if (!res.ok) return null;
return res.json();
}
async function defaultBulk(status) {
if (!selected.size) return;
const res = await fetch(`${apiBase}/tasks/bulk`, {
@@ -303,7 +524,15 @@ export function createInteractiveBoard(root, opts = {}) {
showTrash: opts.showTrash !== false,
selected,
onSelect: opts.interactive !== false ? onSelect : null,
onOpen: (card) => openTaskDrawer(card, { apiBase: opts.drawerApiBase || apiBase, onRefresh: refresh }),
onOpen: (card) => {
if (openDrawer && openDrawer.isOpen()) openDrawer.close();
openDrawer = openTaskDrawer(card, {
apiBase: drawerApiBase,
onRefresh: refresh,
fetchTask,
onClose: () => { openDrawer = null; },
});
},
onMove: async (taskId, status) => {
await (opts.onMove || defaultMove)(taskId, status);
await refresh();
@@ -319,12 +548,28 @@ export function createInteractiveBoard(root, opts = {}) {
}
}
function debouncedRefresh() {
if (!debounceMs) { refresh(); return; }
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = null; refresh(); }, debounceMs);
}
function handleEvent(raw) {
let ev = null;
try { ev = raw ? JSON.parse(raw) : null; } catch (_) { ev = null; }
if (openDrawer && openDrawer.isOpen() && ev && ev.task_id && ev.task_id === openDrawer.id) {
openDrawer.refresh();
}
debouncedRefresh();
}
function connectEvents() {
if (!opts.eventsUrl || typeof EventSource === 'undefined') return;
const url = opts.eventsUrl + (opts.eventsUrl.includes('?') ? '&' : '?') + `board=${encodeURIComponent(opts.currentBoard || 'default')}`;
const activeBoard = opts.boardState?.id || opts.currentBoard || 'default';
const url = opts.eventsUrl + (opts.eventsUrl.includes('?') ? '&' : '?') + `board=${encodeURIComponent(activeBoard)}`;
try {
eventSource = new EventSource(url);
eventSource.onmessage = () => refresh();
eventSource.onmessage = (e) => handleEvent(e && e.data);
} catch (_) { /* polling fallback */ }
}
@@ -337,7 +582,9 @@ export function createInteractiveBoard(root, opts = {}) {
destroy() {
destroyed = true;
if (pollTimer) clearInterval(pollTimer);
if (debounceTimer) clearTimeout(debounceTimer);
if (eventSource) eventSource.close();
if (openDrawer && openDrawer.isOpen()) openDrawer.close();
root.innerHTML = '';
},
};
@@ -348,4 +595,4 @@ export function createBoard(root, opts = {}) {
return createInteractiveBoard(root, { ...opts, interactive: false, showTrash: false });
}
export { renderBoardDOM, el };
export { renderBoardDOM, el, renderMarkdown, relativeTime, escapeHtml };
@@ -408,6 +408,37 @@ const DASHBOARD_STYLE = `
.aiui-board-drawer-body { white-space: pre-wrap; font-size: 13px; color: var(--db-text-dim); }
.aiui-board-drawer-meta { font-size: 12px; color: var(--db-text-dim); margin: 8px 0; }
.aiui-board-drawer-actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.aiui-board-card-dense { padding: 10px 12px; }
.aiui-board-card-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.aiui-board-card-check { margin: 0; cursor: pointer; }
.aiui-board-card-id { font-family: var(--db-mono, monospace); font-size: 10px; color: var(--db-text-dim); }
.aiui-board-status-dot { font-size: 10px; line-height: 1; }
.aiui-board-priority { margin-left: auto; font-size: 10px; opacity: 0.85; }
.aiui-board-card-name { font-size: 13px; text-transform: none; letter-spacing: 0; color: var(--db-text); margin: 0 0 6px; font-weight: 500; }
.aiui-board-card-foot { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11px; color: var(--db-text-dim); }
.aiui-board-chip { padding: 1px 6px; border-radius: 999px; background: rgba(255,255,255,0.06); font-size: 10px; }
.aiui-board-tenant { background: var(--db-accent-glow); color: var(--db-accent); }
.aiui-board-card-age { opacity: 0.8; }
.aiui-board-card-count { opacity: 0.9; }
.aiui-board-progress { margin-left: auto; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--db-border); font-size: 10px; }
.aiui-board-drawer-field { margin: 8px 0; }
.aiui-board-drawer-label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--db-text-dim); margin-bottom: 4px; }
.aiui-board-drawer-input { width: 100%; padding: 6px 8px; background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); color: var(--db-text); font-size: 13px; }
.aiui-board-drawer-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.aiui-board-drawer-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--db-text-dim); margin: 16px 0 8px; }
.aiui-board-markdown { font-size: 13px; line-height: 1.5; color: var(--db-text-dim); }
.aiui-board-markdown p { margin: 0 0 6px; }
.aiui-board-markdown pre { background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); padding: 8px; overflow-x: auto; }
.aiui-board-markdown code { font-family: var(--db-mono, monospace); font-size: 12px; }
.aiui-board-history { display: flex; flex-direction: column; gap: 4px; }
.aiui-board-history-row { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--db-text-dim); }
.aiui-board-history-when { opacity: 0.7; white-space: nowrap; }
.aiui-board-comments { display: flex; flex-direction: column; gap: 10px; }
.aiui-board-comment { border-left: 2px solid var(--db-border); padding-left: 10px; }
.aiui-board-comment-head { display: flex; gap: 8px; align-items: baseline; }
.aiui-board-comment-author { font-size: 12px; font-weight: 600; color: var(--db-text); }
.aiui-board-comment-when { font-size: 11px; color: var(--db-text-dim); }
.aiui-board-comment-input { width: 100%; margin-top: 10px; padding: 8px; background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); color: var(--db-text); font-size: 13px; resize: vertical; }
.aiui-board-page-header { margin-bottom: 8px; }
.db-slot-host { min-height: 0; }
.db-sidebar-footer { margin-top: auto; padding: 10px 14px; border-top: 1px solid var(--db-border); font-size: 11px; color: var(--db-text-dim); }
+95 -1
View File
@@ -5,7 +5,12 @@ from __future__ import annotations
from starlette.testclient import TestClient
from praisonaiui.backends import clear_backends, set_backend
from praisonaiui.features.kanban import get_kanban_store, reset_kanban_store
from praisonaiui.features.kanban import (
_child_progress,
_task_card,
get_kanban_store,
reset_kanban_store,
)
from praisonaiui.server import create_app, reset_state
@@ -81,6 +86,9 @@ def test_kanban_uses_injected_store():
def move_task(self, task_id, status):
return None
def add_comment(self, task_id, comment):
return None
def bulk_update(self, task_ids, status):
return {"updated": 0}
@@ -105,6 +113,92 @@ def test_kanban_uses_injected_store():
reset_kanban_store()
def test_task_card_enriched_dto():
card = _task_card(
{
"id": "t1",
"title": "Refactor auth",
"status": "running",
"assignee": "coder",
"priority": "P2",
"tenant": "acme",
"created_at": "2024-01-01T00:00:00+00:00",
"comments": [{"text": "a"}, {"text": "b"}],
}
)
assert card["tenant"] == "acme"
assert card["created_at"] == "2024-01-01T00:00:00+00:00"
assert card["comment_count"] == 2
assert card["progress"] is None
def test_child_progress_from_children_and_meta():
children = {"children": [{"status": "done"}, {"status": "ready"}, {"status": "done"}]}
assert _child_progress(children) == {"done": 2, "total": 3}
explicit = {"meta": {"progress": {"done": 1, "total": 4}}}
assert _child_progress(explicit) == {"done": 1, "total": 4}
assert _child_progress({}) is None
assert _child_progress({"meta": {"progress": {"done": 0, "total": 0}}}) is None
def test_card_in_board_carries_progress_and_counts():
clear_backends()
client = _client()
store = get_kanban_store()
store.create_task(
{
"id": "px",
"title": "Parent",
"status": "todo",
"tenant": "acme",
"children": [{"status": "done"}, {"status": "todo"}],
}
)
board = client.get("/api/kanban/board").json()
todo_cards = next(c for c in board["columns"] if c["id"] == "todo")["cards"]
card = next(c for c in todo_cards if c["id"] == "px")
assert card["tenant"] == "acme"
assert card["progress"] == {"done": 1, "total": 2}
assert card["comment_count"] == 0
clear_backends()
def test_kanban_add_comment():
clear_backends()
client = _client()
create = client.post("/api/kanban/tasks", json={"title": "Has comments", "status": "todo"})
task_id = create.json()["id"]
resp = client.post(f"/api/kanban/tasks/{task_id}/comments", json={"text": "please unblock"})
assert resp.status_code == 200
task = resp.json()
assert len(task["comments"]) == 1
assert task["comments"][0]["text"] == "please unblock"
assert task["comments"][0]["author"] == "human"
board = client.get("/api/kanban/board").json()
todo_cards = next(c for c in board["columns"] if c["id"] == "todo")["cards"]
card = next(c for c in todo_cards if c["id"] == task_id)
assert card["comment_count"] == 1
clear_backends()
def test_kanban_add_comment_validation():
clear_backends()
client = _client()
create = client.post("/api/kanban/tasks", json={"title": "T", "status": "todo"})
task_id = create.json()["id"]
empty = client.post(f"/api/kanban/tasks/{task_id}/comments", json={"text": " "})
assert empty.status_code == 400
missing = client.post("/api/kanban/tasks/nope/comments", json={"text": "hi"})
assert missing.status_code == 404
clear_backends()
def test_jobs_board_endpoint():
clear_backends()
client = _client()