Stage 319: PR #1886 — Kanban lifecycle controls by @franksong2702

This commit is contained in:
nesquena-hermes
2026-05-08 15:19:04 +00:00
4 changed files with 42 additions and 15 deletions
+16 -8
View File
@@ -490,6 +490,7 @@ const LOCALES = {
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_work_queue_hint: 'This is the Hermes Agent work queue. Create or triage a task, assign it, move it to Ready, then let the dispatcher claim it.',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
@@ -515,7 +516,7 @@ const LOCALES = {
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_nudge_dispatcher: 'Preview dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
@@ -1509,6 +1510,7 @@ const LOCALES = {
kanban_include_archived: 'アーカイブを含める',
kanban_no_matching_tasks: '一致するタスクがありません',
kanban_no_data: 'カンバンデータがありません',
kanban_work_queue_hint: 'これは Hermes Agent のワークキューです。タスクを作成またはトリアージし、担当者を割り当て、Ready に移動すると、ディスパッチャーがそれをクレームします。',
kanban_unavailable: 'カンバンを利用できません',
kanban_read_only: '読み取り専用',
kanban_empty: '空',
@@ -1534,7 +1536,7 @@ const LOCALES = {
kanban_add_comment: 'コメント追加',
kanban_only_mine: '自分のみ',
kanban_bulk_action: '一括操作',
kanban_nudge_dispatcher: 'ディスパッチャーに催促',
kanban_nudge_dispatcher: 'ディスパッチャープレビュー',
kanban_stats: '統計',
kanban_worker_log: 'ワーカーログ',
kanban_block: 'ブロック',
@@ -2367,6 +2369,7 @@ const LOCALES = {
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_work_queue_hint: 'This is the Hermes Agent work queue. Create or triage a task, assign it, move it to Ready, then let the dispatcher claim it.',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
@@ -2392,7 +2395,7 @@ const LOCALES = {
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_nudge_dispatcher: 'Preview dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
@@ -3320,6 +3323,7 @@ const LOCALES = {
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_work_queue_hint: 'This is the Hermes Agent work queue. Create or triage a task, assign it, move it to Ready, then let the dispatcher claim it.',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
@@ -3345,7 +3349,7 @@ const LOCALES = {
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_nudge_dispatcher: 'Preview dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
@@ -4261,6 +4265,7 @@ const LOCALES = {
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_work_queue_hint: 'This is the Hermes Agent work queue. Create or triage a task, assign it, move it to Ready, then let the dispatcher claim it.',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
@@ -4286,7 +4291,7 @@ const LOCALES = {
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_nudge_dispatcher: 'Preview dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
@@ -5223,6 +5228,7 @@ const LOCALES = {
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_work_queue_hint: 'This is the Hermes Agent work queue. Create or triage a task, assign it, move it to Ready, then let the dispatcher claim it.',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
@@ -5248,7 +5254,7 @@ const LOCALES = {
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_nudge_dispatcher: 'Preview dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
@@ -7216,6 +7222,7 @@ const LOCALES = {
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_work_queue_hint: 'This is the Hermes Agent work queue. Create or triage a task, assign it, move it to Ready, then let the dispatcher claim it.',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
@@ -7241,7 +7248,7 @@ const LOCALES = {
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_nudge_dispatcher: 'Preview dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
@@ -8133,6 +8140,7 @@ const LOCALES = {
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_work_queue_hint: 'This is the Hermes Agent work queue. Create or triage a task, assign it, move it to Ready, then let the dispatcher claim it.',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
@@ -8158,7 +8166,7 @@ const LOCALES = {
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_nudge_dispatcher: 'Preview dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
+3 -3
View File
@@ -158,9 +158,9 @@
<label class="kanban-check"><input id="kanbanOnlyMine" type="checkbox" onchange="loadKanban(true)"> <span data-i18n="kanban_only_mine">Only mine</span></label>
<div id="kanbanStats" class="kanban-stats" aria-live="polite"></div>
<div id="kanbanBulkBar" class="kanban-bulk-bar">
<select id="kanbanBulkStatus" aria-label="Bulk status"><option value="">Status</option><option value="ready">Ready</option><option value="running">Running</option><option value="blocked">Blocked</option><option value="done">Done</option><option value="archived">Archived</option></select>
<select id="kanbanBulkStatus" aria-label="Bulk status"><option value="">Status</option><option value="ready">Ready</option><option value="blocked">Blocked</option><option value="done">Done</option><option value="archived">Archived</option></select>
<button class="btn secondary" onclick="bulkUpdateKanban()" data-i18n="kanban_bulk_action">Bulk action</button>
<button class="btn secondary" onclick="nudgeKanbanDispatcher()" data-i18n="kanban_nudge_dispatcher">Nudge dispatcher</button>
<button class="btn secondary" onclick="nudgeKanbanDispatcher()" data-i18n="kanban_nudge_dispatcher">Preview dispatcher</button>
</div>
<div class="kanban-new-task-row">
<input id="kanbanNewTaskTitle" placeholder="New task" data-i18n-placeholder="kanban_new_task" onkeydown="if(event.key==='Enter')createKanbanTask()">
@@ -709,7 +709,7 @@
</div>
<div class="main-view-actions">
<button class="panel-head-btn has-tooltip has-tooltip--bottom" id="btnKanbanCreateBoard" onclick="openKanbanCreateBoard()" data-tooltip="New board" data-i18n-title="kanban_new_board" aria-label="New board"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><line x1="17.5" y1="14" x2="17.5" y2="21"/><line x1="14" y1="17.5" x2="21" y2="17.5"/></svg></button>
<button class="panel-head-btn has-tooltip has-tooltip--bottom" onclick="nudgeKanbanDispatcher()" data-tooltip="Nudge dispatcher" data-i18n-title="kanban_nudge_dispatcher" aria-label="Nudge dispatcher"></button>
<button class="panel-head-btn has-tooltip has-tooltip--bottom" onclick="nudgeKanbanDispatcher()" data-tooltip="Preview dispatcher" data-i18n-title="kanban_nudge_dispatcher" aria-label="Preview dispatcher"></button>
</div>
</div>
<div class="kanban-task-preview" id="kanbanTaskPreview" style="display:none"></div>
+11 -3
View File
@@ -1096,10 +1096,9 @@ function _kanbanCardStalenessClass(task){
function _kanbanCardQuickActions(task){
const id = esc(task.id || '');
const status = task.status || '';
const start = status !== 'running' && status !== 'done' && status !== 'archived' ? `<button type="button" class="kanban-card-action" onclick="quickKanbanCardAction(event,'${id}','running')">${esc(t('kanban_card_start'))}</button>` : '';
const complete = status !== 'done' && status !== 'archived' ? `<button type="button" class="kanban-card-action" onclick="quickKanbanCardAction(event,'${id}','done')">${esc(t('kanban_card_complete'))}</button>` : '';
const archive = status !== 'archived' ? `<button type="button" class="kanban-card-action danger" onclick="quickKanbanCardAction(event,'${id}','archived')">${esc(t('kanban_card_archive'))}</button>` : '';
return `<div class="kanban-card-actions" onclick="event.stopPropagation()">${start}${complete}${archive}</div>`;
return `<div class="kanban-card-actions" onclick="event.stopPropagation()">${complete}${archive}</div>`;
}
async function quickKanbanCardAction(event, taskId, status){
@@ -1168,17 +1167,25 @@ function _kanbanRenderProfileLanes(columns){
}).join('')}</div>`;
}
function _kanbanEmptyBoardHtml(){
return `<div class="main-view-empty"><div class="main-view-empty-title">${esc(t('kanban_no_data'))}</div><div class="main-view-empty-sub">${esc(t('kanban_work_queue_hint'))}</div></div>`;
}
function _kanbanRenderBoard(){
const board = $('kanbanBoard');
if (!board) return;
if (!_kanbanBoard || !_kanbanBoard.columns) {
board.innerHTML = `<div class="main-view-empty"><div class="main-view-empty-title">${esc(t('kanban_no_data'))}</div></div>`;
board.innerHTML = _kanbanEmptyBoardHtml();
return;
}
const columns = _kanbanVisibleTasks();
const total = columns.reduce((n, col) => n + (col.tasks || []).length, 0);
if ($('kanbanSummary')) $('kanbanSummary').textContent = String(t('kanban_visible_tasks')).replace('{0}', total);
_kanbanRenderSidebar(columns);
if (total === 0) {
board.innerHTML = _kanbanEmptyBoardHtml();
return;
}
board.innerHTML = _kanbanLanesByProfile ? _kanbanRenderProfileLanes(columns) : columns.map(_kanbanRenderColumn).join('');
}
@@ -1219,6 +1226,7 @@ async function hardRefreshWebUIClient(){
function _kanbanLooksLikeStaleClientError(err){
const msg = String((err && err.message) || err || '').toLowerCase();
return !!(err && err.status === 404 && (
msg === 'not found' ||
msg.includes('unknown kanban endpoint') ||
msg.includes('stale cached bundle')
));
+12 -1
View File
@@ -164,6 +164,7 @@ def test_kanban_dashboard_parity_i18n_keys_exist():
"kanban_only_mine",
"kanban_bulk_action",
"kanban_nudge_dispatcher",
"kanban_work_queue_hint",
"kanban_stats",
"kanban_worker_log",
"kanban_block",
@@ -205,6 +206,16 @@ def test_kanban_ui_parity_polish_adds_card_metadata_quick_actions_and_swimlanes(
assert "javascript:" not in PANELS.lower()
def test_kanban_lifecycle_controls_do_not_offer_manual_running_start():
assert "quickKanbanCardAction(event,'${id}','running')" not in PANELS
assert "kanban_card_start" not in PANELS
assert '<option value="running">Running</option>' not in INDEX
assert "Cannot set status to 'running' directly" not in PANELS
assert "kanban_work_queue_hint" in PANELS
assert "Preview dispatcher" in INDEX
assert "Nudge dispatcher" not in INDEX
def test_kanban_ui_parity_polish_css_and_i18n_exist():
for selector in (
".kanban-profile-lanes",
@@ -219,7 +230,7 @@ def test_kanban_ui_parity_polish_css_and_i18n_exist():
):
assert selector in STYLE
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
required_keys = ["kanban_lanes_by_profile", "kanban_card_start", "kanban_card_complete", "kanban_card_archive", "kanban_unassigned"]
required_keys = ["kanban_lanes_by_profile", "kanban_card_complete", "kanban_card_archive", "kanban_unassigned", "kanban_work_queue_hint"]
missing = [
f"{locale}:{key}"
for locale, body in locale_blocks