feat: harden Kanban review feedback

- add canonical PATCH and DELETE routing for Kanban writes
- fix task detail log rendering and add close/back affordance
- improve timestamps, event summaries, stats HUD, and mobile layout
- cover route and detail behavior with targeted tests
This commit is contained in:
Manfred authored and test committed 2026-05-04 22:56:43 +00:00
1 parent d7671f8366
commit 711e33e7db
8 files changed
+223 -15

No files matched your search

+31
View File
@@ -594,3 +594,34 @@ def handle_kanban_post(handler, parsed, body) -> bool:
except RuntimeError as exc:
return bad(handler, str(exc), status=409)
return False
def handle_kanban_patch(handler, parsed, body) -> bool:
path = parsed.path
try:
if path.startswith(_TASK_PREFIX):
task_id = unquote(path[len(_TASK_PREFIX):]).strip("/")
if not task_id or "/" in task_id:
return False
return j(handler, _patch_task_payload(task_id, body)) or True
except LookupError as exc:
return bad(handler, str(exc), status=404)
except ValueError as exc:
return bad(handler, str(exc))
except RuntimeError as exc:
return bad(handler, str(exc), status=409)
return False
def handle_kanban_delete(handler, parsed, body) -> bool:
path = parsed.path
try:
if path == "/api/kanban/links":
return j(handler, _link_tasks_payload(body, unlink=True)) or True
except LookupError as exc:
return bad(handler, str(exc), status=404)
except ValueError as exc:
return bad(handler, str(exc))
except RuntimeError as exc:
return bad(handler, str(exc), status=409)
return False
+24
View File
@@ -3778,6 +3778,30 @@ def handle_post(handler, parsed) -> bool:
return False # 404
def handle_patch(handler, parsed) -> bool:
"""Handle all PATCH routes. Returns True if handled, False for 404."""
if not _check_csrf(handler):
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_patch
return handle_kanban_patch(handler, parsed, body)
return False
def handle_delete(handler, parsed) -> bool:
"""Handle all DELETE routes. Returns True if handled, False for 404."""
if not _check_csrf(handler):
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_delete
return handle_kanban_delete(handler, parsed, body)
return False
# ── GET route helpers ─────────────────────────────────────────────────────────
# MIME types for static file serving. Hoisted to module scope to avoid
+12 -3
View File
@@ -22,7 +22,7 @@ from api.auth import check_auth
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
from api.helpers import j, get_profile_cookie
from api.profiles import set_request_profile, clear_request_profile
from api.routes import handle_get, handle_post
from api.routes import handle_delete, handle_get, handle_patch, handle_post
from api.startup import auto_install_agent_deps, fix_credential_permissions
from api.updates import WEBUI_VERSION
@@ -137,7 +137,7 @@ class Handler(BaseHTTPRequestHandler):
finally:
clear_request_profile()
def do_POST(self) -> None:
def _handle_write(self, route_func) -> None:
self._req_t0 = time.time()
# Per-request profile context from cookie (issue #798)
cookie_profile = get_profile_cookie(self)
@@ -146,7 +146,7 @@ class Handler(BaseHTTPRequestHandler):
try:
parsed = urlparse(self.path)
if not check_auth(self, parsed): return
result = handle_post(self, parsed)
result = route_func(self, parsed)
if result is False:
return j(self, {'error': 'not found'}, status=404)
except Exception as e:
@@ -155,6 +155,15 @@ class Handler(BaseHTTPRequestHandler):
finally:
clear_request_profile()
def do_POST(self) -> None:
self._handle_write(handle_post)
def do_PATCH(self) -> None:
self._handle_write(handle_patch)
def do_DELETE(self) -> None:
self._handle_write(handle_delete)
def _raise_fd_soft_limit(target: int = 4096) -> dict:
"""Best-effort raise of RLIMIT_NOFILE for persistent WebUI hosts.
+8
View File
@@ -488,6 +488,7 @@ const LOCALES = {
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
@@ -1435,6 +1436,7 @@ const LOCALES = {
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
@@ -2224,6 +2226,7 @@ const LOCALES = {
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
@@ -3107,6 +3110,7 @@ const LOCALES = {
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
@@ -3978,6 +3982,7 @@ const LOCALES = {
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
@@ -4870,6 +4875,7 @@ const LOCALES = {
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
@@ -6763,6 +6769,7 @@ const LOCALES = {
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
@@ -7616,6 +7623,7 @@ const LOCALES = {
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
+55 -10
View File
@@ -1116,7 +1116,10 @@ async function loadKanbanStats(){
if (!el) return;
const byStatus = (stats && stats.by_status) || {};
const total = Object.values(byStatus).reduce((a, b) => a + Number(b || 0), 0);
el.textContent = `${t('kanban_stats')}: ${total}`;
const cells = Object.entries(byStatus).sort(([a], [b]) => a.localeCompare(b)).map(([status, count]) =>
`<span class="kanban-stat-cell"><strong>${esc(String(count))}</strong> ${esc(_kanbanColumnLabel(status))}</span>`
).join('');
el.innerHTML = `<div class="kanban-stats-grid"><span class="kanban-stat-cell total"><strong>${esc(String(total))}</strong> ${esc(t('kanban_stats'))}</span>${cells}</div>`;
} catch(e) { /* stats are best-effort */ }
}
@@ -1179,6 +1182,45 @@ async function unblockKanbanTask(taskId){
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
}
function closeKanbanTaskDetail(){
_kanbanCurrentTaskId = null;
const preview = $('kanbanTaskPreview');
if (preview) {
preview.style.display = 'none';
preview.innerHTML = '';
}
const board = $('kanbanBoard');
if (board) board.querySelectorAll('.kanban-card').forEach(card => card.classList.remove('selected'));
}
function _kanbanFormatTimestamp(value){
if (value === undefined || value === null || value === '') return '';
let date = null;
if (typeof value === 'number') date = new Date(value > 100000000000 ? value : value * 1000);
else if (/^\d+(?:\.\d+)?$/.test(String(value).trim())) {
const n = Number(value);
date = new Date(n > 100000000000 ? n : n * 1000);
} else {
date = new Date(value);
}
if (!date || Number.isNaN(date.getTime())) return String(value);
try { return date.toLocaleString(); } catch(e) { return date.toISOString(); }
}
function _kanbanEventSummary(event){
const kind = event.kind || event.type || 'event';
const payload = event.payload || event.data || {};
if (payload && typeof payload === 'object') {
const parts = [];
if (payload.status) parts.push(String(payload.status));
if (payload.reason) parts.push(String(payload.reason));
if (payload.summary) parts.push(String(payload.summary));
if (payload.fields && Array.isArray(payload.fields)) parts.push(payload.fields.join(', '));
if (parts.length) return `${kind}: ${parts.join(' · ')}`;
}
return String(kind);
}
function _kanbanFormatDetailValue(value){
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'object') {
@@ -1198,7 +1240,7 @@ function _kanbanDetailSection(cls, title, inner, emptyKey){
function _kanbanCommentHtml(comment){
const body = comment.body || comment.text || comment.content || '';
const by = comment.author || comment.created_by || comment.actor || '';
const at = comment.created_at || comment.ts || '';
const at = _kanbanFormatTimestamp(comment.created_at || comment.ts || '');
return `<div class="kanban-detail-row">
<div class="kanban-detail-row-main">${esc(body)}</div>
<div class="kanban-detail-row-meta">${esc([by, at].filter(Boolean).join(' · '))}</div>
@@ -1206,11 +1248,10 @@ function _kanbanCommentHtml(comment){
}
function _kanbanEventHtml(event){
const kind = event.kind || event.type || 'event';
const at = event.created_at || event.ts || '';
const at = _kanbanFormatTimestamp(event.created_at || event.ts || '');
const payload = _kanbanFormatDetailValue(event.payload || event.data || '');
return `<div class="kanban-detail-row">
<div class="kanban-detail-row-main">${esc(kind)}</div>
<div class="kanban-detail-row-main">${esc(_kanbanEventSummary(event))}</div>
${payload ? `<pre class="kanban-detail-pre">${esc(payload)}</pre>` : ''}
<div class="kanban-detail-row-meta">${esc(at)}</div>
</div>`;
@@ -1219,8 +1260,8 @@ function _kanbanEventHtml(event){
function _kanbanRunHtml(run){
const status = run.status || run.state || run.result || '';
const label = run.run_id || run.id || run.worker || t('kanban_task');
const started = run.started_at || run.created_at || '';
const finished = run.finished_at || run.completed_at || '';
const started = _kanbanFormatTimestamp(run.started_at || run.created_at || '');
const finished = _kanbanFormatTimestamp(run.finished_at || run.completed_at || '');
const detail = run.error || run.summary || run.log_tail || '';
return `<div class="kanban-detail-row">
<div class="kanban-detail-row-main">${esc(label)}${status ? ` · ${esc(status)}` : ''}</div>
@@ -1258,8 +1299,8 @@ async function createKanbanTask(){
async function updateKanbanTask(taskId, patch){
if (!taskId || !patch) return;
try {
const updated = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + '/patch', {
method: 'POST',
const updated = await api('/api/kanban/tasks/' + encodeURIComponent(taskId), {
method: 'PATCH',
body: JSON.stringify(patch),
});
await loadKanban(true);
@@ -1283,6 +1324,7 @@ async function addKanbanComment(taskId){
function _kanbanRenderTaskDetail(data){
const task = data.task || {};
const log = data.log || {};
const title = _kanbanTaskTitle(task);
const body = _kanbanTaskBody(task) || t('kanban_no_description');
const meta = _kanbanTaskMeta(task);
@@ -1293,7 +1335,10 @@ function _kanbanRenderTaskDetail(data){
const statusButtons = ['triage', 'todo', 'ready', 'running', 'blocked', 'done', 'archived'].map(status =>
`<button class="btn secondary" onclick="updateKanbanTask('${esc(task.id)}',{status:'${status}'})">${esc(_kanbanColumnLabel(status))}</button>`
).join('') + `<button class="btn secondary" onclick="blockKanbanTask('${esc(task.id)}')">${esc(t('kanban_block'))}</button><button class="btn secondary" onclick="unblockKanbanTask('${esc(task.id)}')">${esc(t('kanban_unblock'))}</button>`;
return `<div class="kanban-task-preview-title">${esc(title)}</div>
return `<div class="kanban-task-preview-header">
<button class="btn secondary kanban-back-btn" onclick="closeKanbanTaskDetail()">${esc(t('kanban_back_to_board'))}</button>
<div class="kanban-task-preview-title">${esc(title)}</div>
</div>
<div class="kanban-task-preview-body">${esc(body)}</div>
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}
<div class="kanban-status-actions">${statusButtons}</div>
+14 -1
View File
@@ -3145,7 +3145,9 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-new-task-row{display:flex;gap:6px;align-items:center;}
.kanban-new-task-row input{flex:1;min-width:0;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 8px;font:inherit;font-size:12px;}
.kanban-task-preview{padding:12px 16px;border-bottom:1px solid var(--border);background:var(--panel);}
.kanban-task-preview-title{font-size:14px;font-weight:650;color:var(--text);margin-bottom:6px;}
.kanban-task-preview-header{display:flex;align-items:center;gap:10px;margin-bottom:6px;}
.kanban-back-btn{flex-shrink:0;font-size:11px;padding:4px 8px;}
.kanban-task-preview-title{font-size:14px;font-weight:650;color:var(--text);margin-bottom:0;}
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;}
.kanban-status-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px;}
.kanban-status-actions .btn{font-size:11px;padding:4px 8px;}
@@ -3165,6 +3167,9 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-detail-links-grid code{display:inline-block;margin:4px 4px 0 0;padding:2px 5px;border-radius:5px;background:var(--input-bg);border:1px solid var(--border);color:var(--text);}
.kanban-stats{font-size:12px;color:var(--muted);padding:2px 0}
.kanban-stats-grid{display:flex;gap:6px;align-items:center;flex-wrap:wrap;}
.kanban-stat-cell{display:inline-flex;gap:4px;align-items:center;border:1px solid var(--border);border-radius:999px;background:var(--input-bg);padding:2px 7px;}
.kanban-stat-cell.total{color:var(--text);}
.kanban-bulk-bar{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.kanban-bulk-bar select{flex:1;min-width:96px;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px}
@@ -3187,3 +3192,11 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-card-stale-red{border-color:rgba(255,95,95,.65)}
.kanban-column.drop-target{outline:2px solid var(--accent);outline-offset:-2px}
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}
@media (max-width: 640px){
.kanban-board{scroll-snap-type:x mandatory;}
.kanban-column{min-width:82vw;scroll-snap-align:start;}
.kanban-task-preview-header{align-items:flex-start;flex-direction:column;}
.kanban-comment-form{flex-direction:column;align-items:stretch;}
.kanban-stats-grid{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px;}
}
+13
View File
@@ -411,3 +411,16 @@ def test_kanban_only_mine_bulk_dispatch_and_block_unblock(monkeypatch):
assert unblocked["task"]["status"] == "ready"
assert dispatch["dry_run"] is True
assert dispatch["max_spawn"] == 2
def test_routes_dispatches_canonical_kanban_patch_and_delete_verbs():
src = open("api/routes.py", encoding="utf-8").read()
server = open("server.py", encoding="utf-8").read()
assert "def do_PATCH" in server
assert "def do_DELETE" in server
assert "self._handle_write(handle_patch)" in server
assert "self._handle_write(handle_delete)" in server
assert 'parsed.path.startswith("/api/kanban/")' in src
assert "handle_kanban_patch(handler, parsed, body)" in src
assert "handle_kanban_delete(handler, parsed, body)" in src
+66 -1
View File
@@ -70,7 +70,8 @@ def test_kanban_write_mvp_has_native_controls_and_api_calls():
assert "async function addKanbanComment" in PANELS
assert "api('/api/kanban/tasks'," in PANELS
assert "method: 'POST'" in PANELS
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/patch'" in PANELS
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId)" in PANELS
assert "method: 'PATCH'" in PANELS
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/comments'" in PANELS
assert "kanban-status-actions" in PANELS
assert "kanban-comment-form" in PANELS
@@ -216,3 +217,67 @@ def test_kanban_ui_parity_polish_css_and_i18n_exist():
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
]
assert missing == []
def test_kanban_review_feedback_static_ui_fixes_exist():
assert "function closeKanbanTaskDetail" in PANELS
assert "kanban-back-btn" in PANELS
assert "function _kanbanFormatTimestamp" in PANELS
assert "function _kanbanEventSummary" in PANELS
assert "data.log || {}" in PANELS
assert ".kanban-task-preview-header" in STYLE
assert ".kanban-back-btn" in STYLE
assert "@media (max-width: 640px)" in STYLE
assert "scroll-snap-type" in STYLE
assert "kanban-stats-grid" in PANELS
def test_kanban_task_detail_renderer_executes_with_log_and_formats_feedback():
import json
import subprocess
script = """
const fs = require('fs');
const vm = require('vm');
const src = fs.readFileSync('static/panels.js', 'utf8');
function esc(value) {
return String(value == null ? '' : value).replace(/[&<>\"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[ch]));
}
const context = {
console,
setInterval(){ return 1; },
document: { querySelectorAll(){ return []; }, getElementById(){ return null; }, addEventListener(){} },
window: { addEventListener(){} },
t(key){
const map = {
kanban_no_description:'No description', kanban_comments_count:'Comments ({0})', kanban_events_count:'Events ({0})',
kanban_links:'Links', kanban_runs_count:'Runs ({0})', kanban_worker_log:'Worker log', kanban_empty:'Empty',
kanban_no_comments:'No comments', kanban_no_events:'No events', kanban_no_runs:'No runs', kanban_add_comment:'Add comment',
kanban_block:'Block', kanban_unblock:'Unblock', kanban_back_to_board:'Back to board', kanban_task:'Task',
kanban_status_triage:'Triage', kanban_status_todo:'Todo', kanban_status_ready:'Ready', kanban_status_running:'Running',
kanban_status_blocked:'Blocked', kanban_status_done:'Done', kanban_status_archived:'Archived'
};
return map[key] || key;
},
esc, $(){ return null; }, api(){}, showToast(){}, li(){ return ''; }, S: {}
};
vm.createContext(context);
vm.runInContext(src, context);
const html = vm.runInContext(`_kanbanRenderTaskDetail({
task:{id:'t_1', title:'Demo', status:'ready', body:'Body'},
comments:[{body:'hello', author:'webui', created_at:1777931496}],
events:[{kind:'blocked', payload:{reason:'waiting'}, created_at:1777931496}],
links:{parents:['t_0'], children:[]},
runs:[],
log:{content:'worker log'}
})`, context);
console.log(JSON.stringify({html}));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
html = json.loads(result.stdout)["html"]
assert "worker log" in html
assert "kanban-back-btn" in html
assert "Back to board" in html
assert "1777931496" not in html
assert "waiting" in html
assert "ReferenceError" not in html