diff --git a/static/i18n.js b/static/i18n.js
index 2ed2a098..a0960172 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -470,6 +470,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_comments_count: 'Comments ({0})',
+ kanban_events_count: 'Events ({0})',
+ kanban_links: 'Links',
+ kanban_parents: 'Parents',
+ kanban_children: 'Children',
+ kanban_runs_count: 'Runs ({0})',
+ kanban_no_comments: 'No comments',
+ kanban_no_events: 'No events',
+ kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Insights',
@@ -1394,6 +1403,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_comments_count: 'Comments ({0})',
+ kanban_events_count: 'Events ({0})',
+ kanban_links: 'Links',
+ kanban_parents: 'Parents',
+ kanban_children: 'Children',
+ kanban_runs_count: 'Runs ({0})',
+ kanban_no_comments: 'No comments',
+ kanban_no_events: 'No events',
+ kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'ToDo',
tab_insights: 'インサイト',
@@ -2160,6 +2178,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_comments_count: 'Comments ({0})',
+ kanban_events_count: 'Events ({0})',
+ kanban_links: 'Links',
+ kanban_parents: 'Parents',
+ kanban_children: 'Children',
+ kanban_runs_count: 'Runs ({0})',
+ kanban_no_comments: 'No comments',
+ kanban_no_events: 'No events',
+ kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Список дел',
tab_insights: 'Аналитика',
@@ -3020,6 +3047,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_comments_count: 'Comments ({0})',
+ kanban_events_count: 'Events ({0})',
+ kanban_links: 'Links',
+ kanban_parents: 'Parents',
+ kanban_children: 'Children',
+ kanban_runs_count: 'Runs ({0})',
+ kanban_no_comments: 'No comments',
+ kanban_no_events: 'No events',
+ kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Analíticas',
@@ -3868,6 +3904,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_comments_count: 'Comments ({0})',
+ kanban_events_count: 'Events ({0})',
+ kanban_links: 'Links',
+ kanban_parents: 'Parents',
+ kanban_children: 'Children',
+ kanban_runs_count: 'Runs ({0})',
+ kanban_no_comments: 'No comments',
+ kanban_no_events: 'No events',
+ kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Statistiken',
@@ -4737,6 +4782,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_comments_count: 'Comments ({0})',
+ kanban_events_count: 'Events ({0})',
+ kanban_links: 'Links',
+ kanban_parents: 'Parents',
+ kanban_children: 'Children',
+ kanban_runs_count: 'Runs ({0})',
+ kanban_no_comments: 'No comments',
+ kanban_no_events: 'No events',
+ kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: '待办',
tab_insights: '统计',
@@ -6607,6 +6661,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_comments_count: 'Comments ({0})',
+ kanban_events_count: 'Events ({0})',
+ kanban_links: 'Links',
+ kanban_parents: 'Parents',
+ kanban_children: 'Children',
+ kanban_runs_count: 'Runs ({0})',
+ kanban_no_comments: 'No comments',
+ kanban_no_events: 'No events',
+ kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Estatísticas',
@@ -7437,6 +7500,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_comments_count: 'Comments ({0})',
+ kanban_events_count: 'Events ({0})',
+ kanban_links: 'Links',
+ kanban_parents: 'Parents',
+ kanban_children: 'Children',
+ kanban_runs_count: 'Runs ({0})',
+ kanban_no_comments: 'No comments',
+ kanban_no_events: 'No events',
+ kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: '통계',
diff --git a/static/panels.js b/static/panels.js
index 40e57e30..eb715864 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -988,13 +988,93 @@ async function loadKanban(animate){
function filterKanban(){ _kanbanRenderBoard(); }
+function _kanbanFormatDetailValue(value){
+ if (value === undefined || value === null || value === '') return '';
+ if (typeof value === 'object') {
+ try { return JSON.stringify(value, null, 2); } catch(e) { return String(value); }
+ }
+ return String(value);
+}
+
+function _kanbanDetailSection(cls, title, inner, emptyKey){
+ const content = inner || `
${esc(t(emptyKey))}
`;
+ return `
+ ${esc(title)}
+ ${content}
+ `;
+}
+
+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 || '';
+ return `
+
${esc(body)}
+
${esc([by, at].filter(Boolean).join(' · '))}
+
`;
+}
+
+function _kanbanEventHtml(event){
+ const kind = event.kind || event.type || 'event';
+ const at = event.created_at || event.ts || '';
+ const payload = _kanbanFormatDetailValue(event.payload || event.data || '');
+ return `
+
${esc(kind)}
+ ${payload ? `
${esc(payload)}` : ''}
+
${esc(at)}
+
`;
+}
+
+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 detail = run.error || run.summary || run.log_tail || '';
+ return `
+
${esc(label)}${status ? ` · ${esc(status)}` : ''}
+ ${detail ? `
${esc(_kanbanFormatDetailValue(detail))}` : ''}
+
${esc([started, finished].filter(Boolean).join(' → '))}
+
`;
+}
+
+function _kanbanLinksHtml(links){
+ const parents = (links && links.parents) || [];
+ const children = (links && links.children) || [];
+ if (!parents.length && !children.length) return '';
+ const item = id => `${esc(id)}`;
+ return `
+
${esc(t('kanban_parents'))}${parents.length ? parents.map(item).join(' ') : esc(t('kanban_empty'))}
+
${esc(t('kanban_children'))}${children.length ? children.map(item).join(' ') : esc(t('kanban_empty'))}
+
`;
+}
+
+function _kanbanRenderTaskDetail(data){
+ const task = data.task || {};
+ const title = _kanbanTaskTitle(task);
+ const body = _kanbanTaskBody(task) || t('kanban_no_description');
+ const meta = _kanbanTaskMeta(task);
+ const comments = data.comments || [];
+ const events = data.events || [];
+ const links = data.links || {};
+ const runs = data.runs || [];
+ return `${esc(title)}
+ ${esc(body)}
+ ${meta.length ? `${esc(meta.join(' · '))}
` : ''}
+
+ ${_kanbanDetailSection('kanban-detail-comments', String(t('kanban_comments_count')).replace('{0}', comments.length), comments.map(_kanbanCommentHtml).join(''), 'kanban_no_comments')}
+ ${_kanbanDetailSection('kanban-detail-events', String(t('kanban_events_count')).replace('{0}', events.length), events.map(_kanbanEventHtml).join(''), 'kanban_no_events')}
+ ${_kanbanDetailSection('kanban-detail-links', t('kanban_links'), _kanbanLinksHtml(links), 'kanban_empty')}
+ ${_kanbanDetailSection('kanban-detail-runs', String(t('kanban_runs_count')).replace('{0}', runs.length), runs.map(_kanbanRunHtml).join(''), 'kanban_no_runs')}
+
`;
+}
+
async function loadKanbanTask(taskId){
if (!taskId) return;
try {
const data = await api('/api/kanban/tasks/' + encodeURIComponent(taskId));
const task = data.task || {};
const title = _kanbanTaskTitle(task);
- const body = _kanbanTaskBody(task) || t('kanban_no_description');
const board = $('kanbanBoard');
if (board) {
board.querySelectorAll('.kanban-card').forEach(card => card.classList.remove('selected'));
@@ -1002,11 +1082,8 @@ async function loadKanbanTask(taskId){
}
const preview = $('kanbanTaskPreview');
if (preview) {
- const meta = _kanbanTaskMeta(task);
preview.style.display = '';
- preview.innerHTML = `${esc(title)}
- ${esc(body)}
- ${meta.length ? `${esc(meta.join(' · '))}
` : ''}`;
+ preview.innerHTML = _kanbanRenderTaskDetail(data);
}
showToast(`${t('kanban_task')}: ${title}`);
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
diff --git a/static/style.css b/static/style.css
index 37e2aed1..dbdbe2ff 100644
--- a/static/style.css
+++ b/static/style.css
@@ -3145,3 +3145,15 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.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-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;}
+
+.kanban-detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-top:12px;}
+.kanban-detail-section{border:1px solid var(--border);border-radius:8px;background:var(--bg);padding:10px;min-width:0;}
+.kanban-detail-section h3{font-size:12px;font-weight:650;color:var(--text);margin:0 0 8px;}
+.kanban-detail-row{padding:8px 0;border-top:1px solid var(--border);}
+.kanban-detail-row:first-of-type{border-top:0;padding-top:0;}
+.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;white-space:pre-wrap;}
+.kanban-detail-row-meta{font-size:10px;color:var(--muted);margin-top:4px;}
+.kanban-detail-pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-word;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:6px;margin:6px 0 0;color:var(--muted);}
+.kanban-detail-empty{font-size:12px;color:var(--muted);}
+.kanban-detail-links-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:12px;color:var(--muted);}
+.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);}
diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py
index b4bd6924..fb0e4b43 100644
--- a/tests/test_kanban_ui_static.py
+++ b/tests/test_kanban_ui_static.py
@@ -49,6 +49,21 @@ def test_kanban_frontend_uses_read_only_relative_api_endpoints():
assert "classList.add('selected')" in PANELS
+def test_kanban_task_detail_renders_read_only_sections():
+ assert "function _kanbanRenderTaskDetail" in PANELS
+ for payload_key in ("data.comments", "data.events", "data.links", "data.runs"):
+ assert payload_key in PANELS
+ for section_class in (
+ "kanban-detail-section",
+ "kanban-detail-comments",
+ "kanban-detail-events",
+ "kanban-detail-links",
+ "kanban-detail-runs",
+ ):
+ assert section_class in PANELS
+ assert "method: 'POST'" not in PANELS[PANELS.find("async function loadKanbanTask"):PANELS.find("function loadTodos")]
+
+
def test_kanban_board_has_native_css_classes():
for selector in (
".kanban-board",
@@ -77,6 +92,13 @@ def test_kanban_i18n_keys_exist_in_every_locale_block():
"kanban_unavailable",
"kanban_read_only",
"kanban_empty",
+ "kanban_comments_count",
+ "kanban_events_count",
+ "kanban_links",
+ "kanban_runs_count",
+ "kanban_no_comments",
+ "kanban_no_events",
+ "kanban_no_runs",
]
missing = [
f"{locale}:{key}"