From 2aa8b1adc05ef5829137118e70a4c66d87e21ec7 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 9 May 2026 13:40:19 -0700 Subject: [PATCH 1/5] fix(i18n): backfill zh-Hant kanban keys --- static/i18n.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/static/i18n.js b/static/i18n.js index dd4d1c17..21c795f9 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -6392,6 +6392,75 @@ const LOCALES = { dashboard_loopback_warning: '儀表板在伺服器上僅限 loopback 存取。請從伺服器本機瀏覽,或使用 --host 0.0.0.0 重新啟動(不安全)。', tab_logs: 'Logs', tab_workspaces: '\u5de5\u4f5c\u5340', + tab_kanban: 'Kanban', + kanban_board: '看板', + kanban_visible_tasks: '{0} 個可見任務', + kanban_search_tasks: '搜尋任務', + kanban_all_assignees: '所有指派對象', + kanban_all_tenants: '所有租戶', + kanban_include_archived: '包含已封存', + kanban_no_matching_tasks: '沒有符合的任務', + kanban_no_data: '沒有看板資料', + kanban_work_queue_hint: '這是 Hermes Agent 的工作佇列。建立或分類任務、指派對象、移至 Ready,然後讓調度器認領。', + kanban_unavailable: '看板無法使用', + kanban_read_only: '唯讀檢視', + kanban_empty: '空', + kanban_task: '任務', + kanban_no_description: '無描述', + kanban_refresh: '重新整理', + kanban_status_triage: '分類', + kanban_status_todo: '待辦', + kanban_status_ready: '準備就緒', + kanban_status_running: '執行中', + kanban_status_blocked: '已封鎖', + kanban_status_done: '完成', + kanban_comments_count: '留言 ({0})', + kanban_events_count: '事件 ({0})', + kanban_links: '連結', + kanban_parents: '父任務', + kanban_children: '子任務', + kanban_runs_count: '執行 ({0})', + kanban_no_comments: '沒有留言', + kanban_no_events: '沒有事件', + kanban_no_runs: '沒有執行紀錄', + kanban_title: '標題', + kanban_description: '描述', + kanban_description_placeholder: '選填 — 需要完成的事項、驗收標準、連結', + kanban_status: '狀態', + kanban_assignee: '指派對象', + kanban_assignee_placeholder: '選填 — 個人資料或名稱', + kanban_tenant: '租戶', + kanban_tenant_placeholder: '預設', + kanban_priority: '優先順序', + kanban_priority_hint: '數字越大越先執行。預設為 0。', + kanban_title_required: '標題為必填。', + kanban_new_task: '新任務', + kanban_add_comment: '新增留言', + kanban_only_mine: '僅顯示我的', + kanban_bulk_action: '批次操作', + kanban_nudge_dispatcher: '預覽調度器', + kanban_stats: '統計', + kanban_worker_log: '工作者日誌', + kanban_block: '封鎖', + kanban_unblock: '解除封鎖', + kanban_back_to_board: '返回看板', + kanban_lanes_by_profile: '按設定檔分泳道', + kanban_new_board: '新看板…', + kanban_rename_board: '重新命名目前看板…', + kanban_archive_board: '封存目前看板…', + kanban_archive_board_confirm: '封存看板「{name}」?任務仍保留在磁碟上,可從 kanban/boards/_archived/ 復原。', + kanban_board_archived: '看板已封存', + kanban_board_name: '名稱', + kanban_board_slug: '代號(小寫、連字號)', + kanban_board_description: '描述(選填)', + kanban_board_icon: '圖示(表情符號,選填)', + kanban_board_color: '顏色(選填)', + kanban_board_name_required: '名稱為必填', + kanban_board_slug_required: '代號為必填', + kanban_card_complete: '完成', + kanban_card_archive: '封存', + kanban_unassigned: '未指派', + kanban_status_archived: '已封存', new_conversation: '新對話', filter_conversations: '篩選對話', scheduled_jobs: '排程任務', From c71312b2e8d673306a7b07cb9c25dc3369345944 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 9 May 2026 20:48:28 +0000 Subject: [PATCH 2/5] feat(kanban): edit task button, real Run dispatcher, assignee dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three connected gaps in the Kanban UX, fixed together because they're load-bearing for the actual work-queue lifecycle: 1. Edit task — the detail view had only status-transition buttons (Triage/ Todo/Ready/Blocked/Done/Archived) plus Block/Unblock and Add comment. No way to edit title, body, assignee, tenant, or priority once the task was created. Backend already supported it via PATCH /api/kanban/tasks/ (api/kanban_bridge.py::_patch_task) — purely a UI gap. Now: an Edit button on the task-detail header opens the existing modal pre-filled with current values, switches the modal title to 'Edit task' and the submit button to 'Save', PATCHes instead of POSTing on submit. 2. Run dispatcher — the existing 'Preview dispatcher' button always passed ?dry_run=1 (nudgeKanbanDispatcher), so it was preview-only. There was literally no UI button anywhere in the WebUI that actually ran the dispatcher to claim Ready tasks and spawn workers. Users had to drop to the CLI. Now: new runKanbanDispatcher() entry point hits /api/kanban/dispatch without dry_run=1, after a showConfirmDialog confirmation because it spawns subprocess workers. Two UI surfaces: a lightning-bolt button in the board header (visually distinct from the dry-run preview ▶), and a primary 'Run dispatcher' button in the sidebar bulk bar next to a relabeled 'Preview' button. Toast result shows concrete numbers from dispatch_once(): 'Dispatched: 1 spawned, 2 skipped (no assignee)' — not just a generic 'OK'. 3. Assignee dropdown — the previous create modal accepted free-text assignee with no validation. The dispatcher (kanban_db.py:3567) only spawns workers when row['assignee'] is a real Hermes profile name; any typo or blank value made the task sit in Ready forever. Now: ` populated from `/api/profiles` (Hermes profile names like `default`, `archivist`, `orchestrator`) with historical board assignees grouped under "Other", plus an explicit "— Unassigned (won't auto-run) —" option. Helper text under the field explains the dispatcher claim contract — without an assignee that matches a Hermes profile, the dispatcher in `hermes_cli/kanban_db.py:3567` skips the task forever, so the dropdown removes the foot-gun. Custom SVG chevron makes the dropdown read visually as a dropdown. (3) New `runKanbanDispatcher()` button (lightning-bolt icon in the board header, primary "Run dispatcher" button in the sidebar bulk bar) calls `/api/kanban/dispatch` WITHOUT `dry_run=1`, after a `showConfirmDialog` confirmation because it spawns subprocess workers. The existing dry-run preview button is preserved and clearly labeled. Toast result reports concrete numbers from `dispatch_once()` — e.g. "Dispatched: 1 spawned, 2 skipped (no assignee)" — so users see what actually happened. New default status for new tasks is `ready` (was `triage`); soft warning if user picks Unassigned + Ready. 19 new i18n keys translated across all 8 supported locales. Adds 3 regression tests in `tests/test_kanban_ui_static.py` pinning the Edit modal flow, the `` assignee contract, and the runKanbanDispatcher / nudgeKanbanDispatcher dry-run distinction. + ## [v0.51.31] — 2026-05-09 — Release H (12-PR contributor batch: image-mode + race fixes + composer drafts + locale parity) ### Added diff --git a/static/i18n.js b/static/i18n.js index dd4d1c17..2a0fa554 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -542,6 +542,23 @@ const LOCALES = { kanban_priority_hint: 'Higher numbers run first. Default 0.', kanban_title_required: 'Title is required.', kanban_new_task: 'New task', + kanban_edit_task: 'Edit task', + kanban_run_dispatcher: 'Run dispatcher', + kanban_run_dispatcher_confirm: 'This will claim Ready tasks on this board and spawn worker subprocesses (one per task, up to 8 per click). Continue?', + kanban_assignee_profiles_label: 'Hermes profiles', + kanban_assignee_other_label: 'Other (CLI lanes / removed profiles)', + kanban_assignee_unassigned: '— Unassigned (won\u2019t auto-run) —', + kanban_ready_needs_assignee: 'You picked Unassigned + Ready. The dispatcher will skip this task. Submit again to confirm, or pick a profile.', + kanban_dispatch_preview_prefix: 'Preview:', + kanban_dispatch_run_prefix: 'Dispatched:', + kanban_dispatch_spawned: 'spawned', + kanban_dispatch_promoted: 'promoted', + kanban_dispatch_reclaimed: 'reclaimed', + kanban_dispatch_skipped_unassigned: 'skipped (no assignee)', + kanban_dispatch_skipped_nonspawnable: 'skipped (unknown profile)', + kanban_dispatch_auto_blocked: 'auto-blocked', + kanban_dispatch_timed_out: 'timed out', + kanban_dispatch_crashed: 'crashed', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', kanban_bulk_action: 'Bulk action', @@ -1592,6 +1609,23 @@ const LOCALES = { kanban_priority: '優先度', kanban_priority_hint: '値が大きいほど優先されます。既定値は 0。', kanban_title_required: 'タイトルは必須です。', + kanban_edit_task: 'タスクを編集', + kanban_run_dispatcher: 'ディスパッチャーを実行', + kanban_run_dispatcher_confirm: 'このボードの「Ready」タスクを取得し、ワーカーサブプロセスを起動します(クリック1回につき最大8件)。続行しますか?', + kanban_assignee_profiles_label: 'Hermes プロファイル', + kanban_assignee_other_label: 'その他 (CLI レーン / 削除されたプロファイル)', + kanban_assignee_unassigned: '— 未割当 (自動実行されません) —', + kanban_ready_needs_assignee: '担当者「未割当」+「Ready」を選択しました。ディスパッチャーはこのタスクをスキップします。続行するにはもう一度送信するか、プロファイルを選んでください。', + kanban_dispatch_preview_prefix: 'プレビュー:', + kanban_dispatch_run_prefix: '実行:', + kanban_dispatch_spawned: '起動', + kanban_dispatch_promoted: '昇格', + kanban_dispatch_reclaimed: '再取得', + kanban_dispatch_skipped_unassigned: 'スキップ (担当者なし)', + kanban_dispatch_skipped_nonspawnable: 'スキップ (不明なプロファイル)', + kanban_dispatch_auto_blocked: '自動ブロック', + kanban_dispatch_timed_out: 'タイムアウト', + kanban_dispatch_crashed: 'クラッシュ', kanban_new_task: '新規タスク', kanban_add_comment: 'コメント追加', kanban_only_mine: '自分のみ', @@ -2476,6 +2510,23 @@ const LOCALES = { kanban_priority: 'Приоритет', kanban_priority_hint: 'Большие числа выполняются первыми. По умолчанию 0.', kanban_title_required: 'Заголовок обязателен.', + kanban_edit_task: 'Редактировать задачу', + kanban_run_dispatcher: 'Запустить диспетчер', + kanban_run_dispatcher_confirm: 'Это захватит задачи в состоянии Ready на этой доске и запустит подпроцессы-исполнители (по одному на задачу, до 8 за нажатие). Продолжить?', + kanban_assignee_profiles_label: 'Профили Hermes', + kanban_assignee_other_label: 'Другие (CLI каналы / удалённые профили)', + kanban_assignee_unassigned: '— Не назначено (не запустится автоматически) —', + kanban_ready_needs_assignee: 'Вы выбрали «Не назначено» + «Ready». Диспетчер пропустит эту задачу. Отправьте ещё раз для подтверждения или выберите профиль.', + kanban_dispatch_preview_prefix: 'Предпросмотр:', + kanban_dispatch_run_prefix: 'Отправлено:', + kanban_dispatch_spawned: 'запущено', + kanban_dispatch_promoted: 'повышено', + kanban_dispatch_reclaimed: 'возвращено', + kanban_dispatch_skipped_unassigned: 'пропущено (без исполнителя)', + kanban_dispatch_skipped_nonspawnable: 'пропущено (неизвестный профиль)', + kanban_dispatch_auto_blocked: 'автоблок', + kanban_dispatch_timed_out: 'таймаут', + kanban_dispatch_crashed: 'падение', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -3461,6 +3512,23 @@ const LOCALES = { kanban_priority: 'Prioridad', kanban_priority_hint: 'Los números más altos se ejecutan primero. Predeterminado: 0.', kanban_title_required: 'El título es obligatorio.', + kanban_edit_task: 'Editar tarea', + kanban_run_dispatcher: 'Ejecutar despachador', + kanban_run_dispatcher_confirm: 'Esto reclamará tareas Ready en este tablero y generará subprocesos worker (uno por tarea, hasta 8 por clic). ¿Continuar?', + kanban_assignee_profiles_label: 'Perfiles Hermes', + kanban_assignee_other_label: 'Otros (carriles CLI / perfiles eliminados)', + kanban_assignee_unassigned: '— Sin asignar (no se ejecutará automáticamente) —', + kanban_ready_needs_assignee: 'Elegiste «Sin asignar» + «Ready». El despachador omitirá esta tarea. Envía de nuevo para confirmar o selecciona un perfil.', + kanban_dispatch_preview_prefix: 'Vista previa:', + kanban_dispatch_run_prefix: 'Despachado:', + kanban_dispatch_spawned: 'generadas', + kanban_dispatch_promoted: 'promovidas', + kanban_dispatch_reclaimed: 'recuperadas', + kanban_dispatch_skipped_unassigned: 'omitidas (sin asignar)', + kanban_dispatch_skipped_nonspawnable: 'omitidas (perfil desconocido)', + kanban_dispatch_auto_blocked: 'auto-bloqueadas', + kanban_dispatch_timed_out: 'expiradas', + kanban_dispatch_crashed: 'fallaron', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -4434,6 +4502,23 @@ const LOCALES = { kanban_priority: 'Priorität', kanban_priority_hint: 'Höhere Zahlen laufen zuerst. Standard: 0.', kanban_title_required: 'Titel ist erforderlich.', + kanban_edit_task: 'Aufgabe bearbeiten', + kanban_run_dispatcher: 'Dispatcher ausführen', + kanban_run_dispatcher_confirm: 'Dies beansprucht Ready-Aufgaben auf diesem Board und startet Worker-Subprozesse (einen pro Aufgabe, bis zu 8 pro Klick). Fortfahren?', + kanban_assignee_profiles_label: 'Hermes-Profile', + kanban_assignee_other_label: 'Andere (CLI-Bahnen / entfernte Profile)', + kanban_assignee_unassigned: '— Nicht zugewiesen (läuft nicht automatisch) —', + kanban_ready_needs_assignee: 'Du hast „Nicht zugewiesen“ + „Ready“ gewählt. Der Dispatcher überspringt diese Aufgabe. Erneut senden zum Bestätigen oder ein Profil wählen.', + kanban_dispatch_preview_prefix: 'Vorschau:', + kanban_dispatch_run_prefix: 'Gestartet:', + kanban_dispatch_spawned: 'gestartet', + kanban_dispatch_promoted: 'befördert', + kanban_dispatch_reclaimed: 'zurückgenommen', + kanban_dispatch_skipped_unassigned: 'übersprungen (kein Bearbeiter)', + kanban_dispatch_skipped_nonspawnable: 'übersprungen (unbekanntes Profil)', + kanban_dispatch_auto_blocked: 'auto-blockiert', + kanban_dispatch_timed_out: 'Zeitüberschreitung', + kanban_dispatch_crashed: 'abgestürzt', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -5428,6 +5513,23 @@ const LOCALES = { kanban_priority: '优先级', kanban_priority_hint: '数值越高越先执行,默认为 0。', kanban_title_required: '标题为必填项。', + kanban_edit_task: '编辑任务', + kanban_run_dispatcher: '运行调度器', + kanban_run_dispatcher_confirm: '将认领此看板的 Ready 任务并生成工作子进程(每次点击最多 8 个)。是否继续?', + kanban_assignee_profiles_label: 'Hermes 配置', + kanban_assignee_other_label: '其他 (CLI 通道 / 已删除的配置)', + kanban_assignee_unassigned: '— 未分配 (不会自动运行) —', + kanban_ready_needs_assignee: '您选择了「未分配」+「Ready」。调度器将跳过此任务。再次提交以确认,或选择一个配置。', + kanban_dispatch_preview_prefix: '预览:', + kanban_dispatch_run_prefix: '已调度:', + kanban_dispatch_spawned: '已启动', + kanban_dispatch_promoted: '已提升', + kanban_dispatch_reclaimed: '已收回', + kanban_dispatch_skipped_unassigned: '跳过 (未分配)', + kanban_dispatch_skipped_nonspawnable: '跳过 (未知配置)', + kanban_dispatch_auto_blocked: '自动阻止', + kanban_dispatch_timed_out: '超时', + kanban_dispatch_crashed: '崩溃', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -7480,6 +7582,23 @@ const LOCALES = { kanban_priority: 'Prioridade', kanban_priority_hint: 'Números maiores executam primeiro. Padrão: 0.', kanban_title_required: 'O título é obrigatório.', + kanban_edit_task: 'Editar tarefa', + kanban_run_dispatcher: 'Executar despachador', + kanban_run_dispatcher_confirm: 'Isso reivindicará tarefas Ready neste quadro e gerará subprocessos worker (um por tarefa, até 8 por clique). Continuar?', + kanban_assignee_profiles_label: 'Perfis Hermes', + kanban_assignee_other_label: 'Outros (faixas CLI / perfis removidos)', + kanban_assignee_unassigned: '— Não atribuído (não rodará automaticamente) —', + kanban_ready_needs_assignee: 'Você selecionou «Não atribuído» + «Ready». O despachador pulará esta tarefa. Envie novamente para confirmar ou escolha um perfil.', + kanban_dispatch_preview_prefix: 'Prévia:', + kanban_dispatch_run_prefix: 'Despachado:', + kanban_dispatch_spawned: 'iniciadas', + kanban_dispatch_promoted: 'promovidas', + kanban_dispatch_reclaimed: 'reclamadas', + kanban_dispatch_skipped_unassigned: 'puladas (sem responsável)', + kanban_dispatch_skipped_nonspawnable: 'puladas (perfil desconhecido)', + kanban_dispatch_auto_blocked: 'auto-bloqueadas', + kanban_dispatch_timed_out: 'tempo esgotado', + kanban_dispatch_crashed: 'falharam', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -8429,6 +8548,23 @@ const LOCALES = { kanban_priority: '우선순위', kanban_priority_hint: '높은 숫자가 먼저 실행됩니다. 기본값: 0.', kanban_title_required: '제목은 필수입니다.', + kanban_edit_task: '작업 편집', + kanban_run_dispatcher: '디스패처 실행', + kanban_run_dispatcher_confirm: '이 보드의 Ready 작업을 클레임하고 워커 서브프로세스를 생성합니다(클릭당 최대 8개). 계속할까요?', + kanban_assignee_profiles_label: 'Hermes 프로필', + kanban_assignee_other_label: '기타 (CLI 레인 / 삭제된 프로필)', + kanban_assignee_unassigned: '— 미할당 (자동 실행되지 않음) —', + kanban_ready_needs_assignee: '«미할당» + «Ready»를 선택했습니다. 디스패처는 이 작업을 건너뜁니다. 확인하려면 다시 제출하거나 프로필을 선택하세요.', + kanban_dispatch_preview_prefix: '미리보기:', + kanban_dispatch_run_prefix: '디스패치됨:', + kanban_dispatch_spawned: '생성됨', + kanban_dispatch_promoted: '승격됨', + kanban_dispatch_reclaimed: '재요청됨', + kanban_dispatch_skipped_unassigned: '건너뜀 (담당자 없음)', + kanban_dispatch_skipped_nonspawnable: '건너뜀 (알 수 없는 프로필)', + kanban_dispatch_auto_blocked: '자동 차단', + kanban_dispatch_timed_out: '시간 초과', + kanban_dispatch_crashed: '충돌', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', diff --git a/static/index.html b/static/index.html index fdd014d2..343d76bc 100644 --- a/static/index.html +++ b/static/index.html @@ -160,7 +160,8 @@
- + +
@@ -718,7 +719,8 @@
- + +
@@ -1290,8 +1292,10 @@
- - + +
Pick a Hermes profile so the dispatcher can claim and run this task. Tasks left as Unassigned will sit in Ready forever.
diff --git a/static/panels.js b/static/panels.js index 8b9dfcc2..50ff369e 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1424,14 +1424,75 @@ function _kanbanBoardQuery(extra){ } async function nudgeKanbanDispatcher(){ + // Dry-run dispatch: show what WOULD be spawned, without actually spawning + // workers. Uses ?dry_run=1 so the dispatcher reports its plan without + // mutating the board. The result shape includes spawned/skipped_unassigned/ + // skipped_nonspawnable/promoted/auto_blocked so users can diagnose why a + // Ready task isn't being picked up before they commit to a real run. try { const dispatchEndpoint = '/api/kanban/dispatch'; - await api(dispatchEndpoint + '?dry_run=1&max=1' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''), {method: 'POST'}); - showToast(t('kanban_nudge_dispatcher')); + const result = await api( + dispatchEndpoint + '?dry_run=1&max=8' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''), + {method: 'POST'}, + ); + showToast(_kanbanFormatDispatchResult(result, true), 'info', 6000); await loadKanban(true); } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } } +async function runKanbanDispatcher(){ + // Real dispatch: claims Ready tasks and spawns worker subprocesses + // (one `hermes -p ` per claimed row, up to max=8 per call). + // Confirmation dialog first because this actually consumes API budget on + // each spawned worker. Result toast surfaces what happened so users see + // the dispatcher actually doing work. + if (!_kanbanCurrentBoard) { + showToast(t('kanban_unavailable') || 'Kanban unavailable', 'error'); + return; + } + const ok = await showConfirmDialog({ + title: t('kanban_run_dispatcher') || 'Run dispatcher', + message: t('kanban_run_dispatcher_confirm') + || 'This will claim Ready tasks on this board and spawn worker subprocesses (one per task, up to 8 per click). Continue?', + confirmLabel: t('kanban_run_dispatcher') || 'Run dispatcher', + }); + if (!ok) return; + try { + const dispatchEndpoint = '/api/kanban/dispatch'; + const result = await api( + dispatchEndpoint + '?max=8' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''), + {method: 'POST'}, + ); + showToast(_kanbanFormatDispatchResult(result, false), 'info', 8000); + await loadKanban(true); + } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } +} + +function _kanbanFormatDispatchResult(result, dryRun){ + // Produce a human-readable one-line summary of dispatch_once's output so + // users can see exactly what happened rather than a generic "OK" toast. + const r = result || {}; + const spawned = (r.spawned || []).length; + const promoted = r.promoted || 0; + const reclaimed = r.reclaimed || 0; + const skippedUnassigned = (r.skipped_unassigned || []).length; + const skippedNonspawnable = (r.skipped_nonspawnable || []).length; + const autoBlocked = (r.auto_blocked || []).length; + const timedOut = (r.timed_out || []).length; + const crashed = (r.crashed || []).length; + const verb = dryRun ? (t('kanban_dispatch_preview_prefix') || 'Preview:') : (t('kanban_dispatch_run_prefix') || 'Dispatched:'); + const parts = []; + parts.push(spawned + ' ' + (t('kanban_dispatch_spawned') || 'spawned')); + if (promoted) parts.push(promoted + ' ' + (t('kanban_dispatch_promoted') || 'promoted')); + if (reclaimed) parts.push(reclaimed + ' ' + (t('kanban_dispatch_reclaimed') || 'reclaimed')); + if (skippedUnassigned) parts.push(skippedUnassigned + ' ' + (t('kanban_dispatch_skipped_unassigned') || 'skipped (no assignee)')); + if (skippedNonspawnable) parts.push(skippedNonspawnable + ' ' + (t('kanban_dispatch_skipped_nonspawnable') || 'skipped (unknown profile)')); + if (autoBlocked) parts.push(autoBlocked + ' ' + (t('kanban_dispatch_auto_blocked') || 'auto-blocked')); + if (timedOut) parts.push(timedOut + ' ' + (t('kanban_dispatch_timed_out') || 'timed out')); + if (crashed) parts.push(crashed + ' ' + (t('kanban_dispatch_crashed') || 'crashed')); + return verb + ' ' + parts.join(', '); +} + function _kanbanSelectedTaskIds(){ const selected = Array.from(document.querySelectorAll('.kanban-card.selected')).map(card => card.dataset.kanbanTaskId).filter(Boolean); return selected.length ? selected : (_kanbanCurrentTaskId ? [_kanbanCurrentTaskId] : []); @@ -1596,48 +1657,222 @@ async function createKanbanTask(){ // assignee, tenant, priority, status — see api/kanban_bridge.py:306). // ──────────────────────────────────────────────────────────────────────────── +// ──────────────────────────────────────────────────────────────────────────── +// Kanban: create-task / edit-task modal (panel-head "+" + task-detail Edit +// button entry points). +// +// Single modal serves both flows. Title + submit-button labels and the +// underlying submit verb (POST vs PATCH) flip based on `_kanbanTaskModalMode`. +// +// Same `.kanban-modal-overlay` shell as openKanbanCreateBoard() so the two +// flows look and behave identically (centered card, dim backdrop, ESC closes, +// click-on-backdrop closes). The modal markup lives in static/index.html as +// #kanbanTaskModal — see the section just above . +// +// The assignee field auto-completes against the union of (a) live Hermes +// profile names from /api/profiles and (b) historical assignees on the +// active board, with an inline hint that explains the dispatcher claim +// contract — most users will pick a profile name from the dropdown rather +// than type one. +// ──────────────────────────────────────────────────────────────────────────── + +let _kanbanTaskModalMode = 'create'; // 'create' | 'edit' +let _kanbanTaskModalEditingId = null; // task id when mode === 'edit' +let _kanbanProfileNamesCache = null; // populated lazily on first modal open + +async function _kanbanLoadProfileNames(){ + // Hit /api/profiles once per session and cache; refresh is cheap if needed. + // Returns an array of profile names (sorted, default first if present). + if (Array.isArray(_kanbanProfileNamesCache)) return _kanbanProfileNamesCache; + try { + const data = await api('/api/profiles'); + const profiles = Array.isArray(data && data.profiles) ? data.profiles : []; + const names = profiles.map(p => p && p.name).filter(Boolean); + // Stable order: default first, then alphabetical. + names.sort((a, b) => { + if (a === 'default') return -1; + if (b === 'default') return 1; + return a.localeCompare(b); + }); + _kanbanProfileNamesCache = names; + return names; + } catch(_) { + _kanbanProfileNamesCache = []; + return []; + } +} + +async function _kanbanPopulateAssigneeSelect(currentValue){ + const sel = document.getElementById('kanbanTaskModalAssignee'); + if (!sel) return; + // Profile names: the canonical set the dispatcher can claim. + const profileNames = await _kanbanLoadProfileNames(); + // Historical assignees from the active board: include them so users who + // assigned to a CLI lane (e.g. orion-cc) before still see those values. + const historicalAssignees = (_kanbanBoard && Array.isArray(_kanbanBoard.assignees)) + ? _kanbanBoard.assignees + : []; + // Build a final ordered list, deduping. Profiles come first, then any + // historical assignees that aren't profiles (rare but keeps round-tripping + // correct for tasks created via CLI). + const seen = new Set(); + const profiles = []; + for (const name of profileNames) { + if (!seen.has(name)) { profiles.push(name); seen.add(name); } + } + const extras = []; + for (const name of historicalAssignees) { + if (name && !seen.has(name)) { extras.push(name); seen.add(name); } + } + // If the current value isn't in either bucket (e.g. an old CLI-created + // assignee that's since been deleted), preserve it as a final option so + // editing the task doesn't silently change its assignee. + if (currentValue && !seen.has(currentValue)) { + extras.push(currentValue); + seen.add(currentValue); + } + // The empty value maps to null on submit (intentionally unassigned). Keep + // it last so the default-selected option is the first profile, not "no one". + let html = ''; + if (profiles.length) { + html += ``; + html += profiles.map(v => ``).join(''); + html += ''; + } + if (extras.length) { + html += ``; + html += extras.map(v => ``).join(''); + html += ''; + } + // Final "no assignee" fallthrough — explicit so users know what they're choosing. + html += ``; + sel.innerHTML = html; +} + function openKanbanCreate(){ // Make sure the user is on the kanban panel so the resulting board reload is - // visible behind the modal. Without this the modal would still work but the - // user could lose context on which panel they triggered it from. + // visible behind the modal. if (typeof switchPanel === 'function' && _currentPanel !== 'kanban') switchPanel('kanban'); const modal = document.getElementById('kanbanTaskModal'); if (!modal) return; - // Reset all form fields to defaults. - const titleEl = document.getElementById('kanbanTaskModalTitleInput'); - const bodyEl = document.getElementById('kanbanTaskModalBody'); - const statusEl = document.getElementById('kanbanTaskModalStatus'); - const assigneeEl = document.getElementById('kanbanTaskModalAssignee'); - const tenantEl = document.getElementById('kanbanTaskModalTenant'); - const priorityEl = document.getElementById('kanbanTaskModalPriority'); - const errEl = document.getElementById('kanbanTaskModalError'); - const submitBtn = document.getElementById('kanbanTaskModalSubmit'); - if (titleEl) titleEl.value = ''; - if (bodyEl) bodyEl.value = ''; - if (statusEl) statusEl.value = 'triage'; - if (assigneeEl) assigneeEl.value = ''; - if (tenantEl) tenantEl.value = ''; - if (priorityEl) priorityEl.value = '0'; - if (errEl) errEl.textContent = ''; - if (submitBtn) submitBtn.disabled = false; - // Populate datalists from the currently-loaded board so the user sees the - // assignees / tenants the dispatcher already knows about. - const assignees = (_kanbanBoard && Array.isArray(_kanbanBoard.assignees)) ? _kanbanBoard.assignees : []; - const tenants = (_kanbanBoard && Array.isArray(_kanbanBoard.tenants)) ? _kanbanBoard.tenants : []; - const aList = document.getElementById('kanbanTaskModalAssigneeList'); - const tList = document.getElementById('kanbanTaskModalTenantList'); - if (aList) aList.innerHTML = assignees.map(v => ``).join(''); - if (tList) tList.innerHTML = tenants.map(v => ``).join(''); + _kanbanTaskModalMode = 'create'; + _kanbanTaskModalEditingId = null; + // Default new tasks to "ready" so they're immediately claimable by the + // dispatcher (assuming the user picks an assignee). Triage is for staging + // tasks that need human review before being marked actionable; users who + // want it can still pick it from the status dropdown. + _kanbanResetTaskModalFields({status: 'ready'}); + _kanbanSetTaskModalLabels('create'); + _kanbanPopulateAssigneeSelect('').then(() => { + // After the dropdown is populated, default-select the first profile (not + // the "Unassigned" fallthrough). This is the right hint: most users want + // to assign to *something* — they can pick "Unassigned" deliberately. + const sel = document.getElementById('kanbanTaskModalAssignee'); + if (sel && sel.options.length > 0 && sel.value === '') { + const firstProfile = Array.from(sel.options).find(opt => opt.value !== ''); + if (firstProfile) sel.value = firstProfile.value; + } + }); + _kanbanPopulateTenantDatalist(); modal.hidden = false; - // Auto-focus title field on open. setTimeout to wait for paint. - setTimeout(() => { if (titleEl) titleEl.focus(); }, 50); - // Bind ESC to close, and Enter on simple inputs to submit. + setTimeout(() => { + const titleEl = document.getElementById('kanbanTaskModalTitleInput'); + if (titleEl) titleEl.focus(); + }, 50); document.addEventListener('keydown', _kanbanTaskModalKey); } +async function openKanbanEdit(taskId){ + // Triggered by the Edit button on the task detail view. Fetches the task + // (rather than relying on whatever's cached locally) so the modal always + // reflects authoritative server state. + if (!taskId) return; + if (typeof switchPanel === 'function' && _currentPanel !== 'kanban') switchPanel('kanban'); + const modal = document.getElementById('kanbanTaskModal'); + if (!modal) return; + let task = null; + try { + const data = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + _kanbanBoardQuery()); + task = data && data.task; + } catch(e) { + showToast((t('kanban_unavailable') || 'Kanban unavailable') + ': ' + (e.message || e), 'error'); + return; + } + if (!task) return; + _kanbanTaskModalMode = 'edit'; + _kanbanTaskModalEditingId = task.id; + _kanbanResetTaskModalFields({ + title: task.title || '', + body: task.body || '', + status: _kanbanEditableStatusFor(task.status), + tenant: task.tenant || '', + priority: typeof task.priority === 'number' ? task.priority : 0, + }); + // Populate the assignee select AFTER reset so the option exists when we + // call sel.value = currentAssignee. + await _kanbanPopulateAssigneeSelect(task.assignee || ''); + _kanbanSetTaskModalLabels('edit'); + _kanbanPopulateTenantDatalist(); + modal.hidden = false; + setTimeout(() => { + const titleEl = document.getElementById('kanbanTaskModalTitleInput'); + if (titleEl) { titleEl.focus(); titleEl.select(); } + }, 50); + document.addEventListener('keydown', _kanbanTaskModalKey); +} + +function _kanbanEditableStatusFor(status){ + // The modal's status select only offers triage/todo/ready (the user-writable + // states). blocked/running/done/archived are reached via the detail-view + // status buttons or the dispatcher. Map non-editable states to a sensible + // default so the user can still change them via the buttons after saving. + const editable = new Set(['triage', 'todo', 'ready']); + return editable.has(status) ? status : 'triage'; +} + +function _kanbanResetTaskModalFields(values){ + const v = values || {}; + const set = (id, val) => { + const el = document.getElementById(id); + if (el) el.value = (val == null ? '' : String(val)); + }; + set('kanbanTaskModalTitleInput', v.title || ''); + set('kanbanTaskModalBody', v.body || ''); + set('kanbanTaskModalStatus', v.status || 'triage'); + // Assignee handled separately by _kanbanPopulateAssigneeSelect() because + // it's a populated from /api/profiles + board history, + # tenant is still a free-text backed by a datalist. + assert "_kanbanPopulateAssigneeSelect" in body, ( + "openKanbanCreate must populate the assignee populated from /api/profiles + board history, + not a free-text input. Free-text invites typos that the dispatcher silently + rejects (kanban_db.py:3567 "if not row[assignee]: skip"), and the dropdown + makes the dispatcher contract explicit. + """ + # The modal markup uses , not . + start = INDEX.rfind('<', 0, sel_idx) + tag_open = INDEX[start:sel_idx + 60] + assert tag_open.startswith(' element, got: {tag_open[:80]!r}" + ) + + # Hint element exists and references the dispatcher claim contract. + assert 'id="kanbanTaskModalAssigneeHint"' in INDEX + hint_idx = INDEX.find('id="kanbanTaskModalAssigneeHint"') + hint_block = INDEX[hint_idx:hint_idx + 400] + assert "Hermes profile" in hint_block or "data-i18n=\"kanban_assignee_hint\"" in hint_block + + # The populator function loads from /api/profiles and groups options. + pop_match = re.search( + r"async function _kanbanPopulateAssigneeSelect\([^)]*\)\{(.*?)\n\}", + PANELS, re.DOTALL, + ) + assert pop_match, "_kanbanPopulateAssigneeSelect() not found" + pop_body = pop_match.group(1) + assert "_kanbanLoadProfileNames" in pop_body + assert "` to + # capture the full element (class= attribute precedes id= in the markup). + btn_start = INDEX.rfind('', btn_idx) + len('') + btn_html = INDEX[btn_start:btn_end] + assert 'onclick="runKanbanDispatcher()"' in btn_html + # Distinct visual class so users can tell it apart from the preview button. + assert "kanban-run-dispatch-btn" in btn_html + + # 4. The sidebar bulk bar also has a Run dispatcher button alongside the + # existing Preview button, so users in the filter pane can also run. + bulk_idx = INDEX.find("kanbanBulkBar") + bulk_html = INDEX[bulk_idx:bulk_idx + 1500] + assert 'onclick="runKanbanDispatcher()"' in bulk_html, ( + "Sidebar bulk bar must also expose Run dispatcher." + ) + # The dispatch result formatter exists and surfaces concrete numbers. + assert "function _kanbanFormatDispatchResult" in PANELS + fmt_match = re.search( + r"function _kanbanFormatDispatchResult\([^)]*\)\{(.*?)\n\}", + PANELS, re.DOTALL, + ) + assert fmt_match + fmt_body = fmt_match.group(1) + for token in ("spawned", "skipped_unassigned", "skipped_nonspawnable", "promoted"): + assert token in fmt_body, f"dispatch summary missing field: {token}" + + def test_kanban_board_has_native_css_classes(): for selector in ( From 8e0eedd163812449edfdd082fc075d269f53eafc Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sat, 9 May 2026 13:57:31 -0700 Subject: [PATCH 3/5] fix(kanban-edit): preserve real status when editing non-{triage,todo,ready} tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1981's edit-task modal silently demotes tasks whose real status is running/blocked/done/archived. The dropdown only offers triage/todo/ready, so `_kanbanEditableStatusFor()` maps any other status to 'triage' for display. If the user just edits the title and saves, the dropdown's displayed 'triage' lands in the PATCH payload — and `_patch_task` calls `_set_status_direct` which: - ends any active run with outcome='reclaimed' (worker yanked back) - nulls claim_lock / claim_expires / worker_pid - moves the task to triage So editing a 'running' task's title would reclaim the running worker. Editing a 'done' task would un-done it. Editing an 'archived' task would un-archive it. All silent, no warning. Reproducer (Node): Original: {status: 'running'} Modal display: 'triage' (mapped) User leaves dropdown alone → submit Payload: {title: 'X', status: 'triage'} ← destructive Fix: track the modal's initial displayed status in _kanbanTaskModalInitialDisplayedStatus on edit-mode open. In submit's edit branch, only include `status` in the PATCH payload when the user actually picked a different value than what the dropdown opened with. Create-mode resets the tracker to null so create payloads always include status. Verified end-to-end via Node harness: - edit running, untouched → no status sent ✓ (server keeps running) - edit running, picked ready → status:ready sent ✓ (worker reclaimed intentionally) - edit triage, untouched → no status sent ✓ (idempotent) - edit triage, picked ready → status:ready sent ✓ - create new → status always sent ✓ - edit done, untouched → no status sent ✓ (no un-done) Adds test_kanban_edit_mode_preserves_status_when_dropdown_untouched pinning the tracker variable, openKanbanEdit captures, submit-skip condition, and create/close reset paths. Verified to fail pre-fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- static/panels.js | 28 +++++++++++++-- tests/test_kanban_ui_static.py | 66 ++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/static/panels.js b/static/panels.js index 50ff369e..8bdc815a 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1679,6 +1679,14 @@ async function createKanbanTask(){ let _kanbanTaskModalMode = 'create'; // 'create' | 'edit' let _kanbanTaskModalEditingId = null; // task id when mode === 'edit' let _kanbanProfileNamesCache = null; // populated lazily on first modal open +// Status the modal *displayed* on edit-mode open. If the user doesn't touch +// the dropdown, we must NOT send `status` in the PATCH payload — otherwise +// editing a task whose real status is non-editable in this dropdown +// (running/blocked/done/archived → mapped to 'triage' for display) would +// silently demote the task on save. See the regression caught during PR +// review: editing a 'running' task without touching status was reclaiming +// the worker and moving the task back to triage. +let _kanbanTaskModalInitialDisplayedStatus = null; async function _kanbanLoadProfileNames(){ // Hit /api/profiles once per session and cache; refresh is cheap if needed. @@ -1757,6 +1765,7 @@ function openKanbanCreate(){ if (!modal) return; _kanbanTaskModalMode = 'create'; _kanbanTaskModalEditingId = null; + _kanbanTaskModalInitialDisplayedStatus = null; // create mode: always send status // Default new tasks to "ready" so they're immediately claimable by the // dispatcher (assuming the user picks an assignee). Triage is for staging // tasks that need human review before being marked actionable; users who @@ -1801,10 +1810,18 @@ async function openKanbanEdit(taskId){ if (!task) return; _kanbanTaskModalMode = 'edit'; _kanbanTaskModalEditingId = task.id; + // Track the displayed status so submitKanbanTaskModal can detect whether + // the user actually picked a new value vs. the dropdown's mapped default. + // Without this, editing a 'running'/'blocked'/'done'/'archived' task whose + // real status maps to 'triage' for display would silently demote the task + // (the mapped 'triage' would land in the PATCH payload, and _patch_task + // would call _set_status_direct → reclaim worker → move to triage). + const initialDisplayedStatus = _kanbanEditableStatusFor(task.status); + _kanbanTaskModalInitialDisplayedStatus = initialDisplayedStatus; _kanbanResetTaskModalFields({ title: task.title || '', body: task.body || '', - status: _kanbanEditableStatusFor(task.status), + status: initialDisplayedStatus, tenant: task.tenant || '', priority: typeof task.priority === 'number' ? task.priority : 0, }); @@ -1873,6 +1890,7 @@ function closeKanbanTaskModal(){ if (modal) modal.hidden = true; _kanbanTaskModalMode = 'create'; _kanbanTaskModalEditingId = null; + _kanbanTaskModalInitialDisplayedStatus = null; document.removeEventListener('keydown', _kanbanTaskModalKey); } @@ -1923,7 +1941,13 @@ async function submitKanbanTaskModal(){ payload.body = bodyVal; payload.assignee = assigneeVal || null; payload.tenant = tenantVal || null; - if (statusVal) payload.status = statusVal; + // Only send status if the user actually changed the dropdown from the + // value the modal opened with. Otherwise editing a 'running'/'blocked'/ + // 'done'/'archived' task — whose real status maps to the dropdown's + // 'triage' default — would silently demote the task on every save. + if (statusVal && statusVal !== _kanbanTaskModalInitialDisplayedStatus) { + payload.status = statusVal; + } const n = parseInt(priorityRaw, 10); payload.priority = Number.isNaN(n) ? 0 : n; } else { diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index ddb7cd56..5171ac31 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -237,6 +237,72 @@ def test_kanban_task_detail_has_edit_button_and_modal_supports_edit_mode(): assert label_match and "edit" in label_match.group(1) +def test_kanban_edit_mode_preserves_status_when_dropdown_untouched(): + """Regression: editing a task whose real status is non-editable in the + modal's status dropdown (running/blocked/done/archived → mapped to + 'triage' for display) must NOT silently demote the task on save. + + The dropdown only offers triage/todo/ready, so `_kanbanEditableStatusFor` + maps any other status to 'triage' for display. If the user just edits + the title and saves, the dropdown's 'triage' default would land in the + PATCH payload and the backend would call `_set_status_direct` which + reclaims any active worker and demotes the task. + + Fix: track the displayed default in `_kanbanTaskModalInitialDisplayedStatus` + and only include `status` in the PATCH payload when the user actually + picked a different value. + """ + # 1. The tracking variable is declared at module scope. + assert "_kanbanTaskModalInitialDisplayedStatus" in PANELS, ( + "Edit-mode status preservation requires tracking the initial displayed " + "status so submit can detect whether the user actually changed it." + ) + + # 2. openKanbanEdit captures the initial displayed status from the task. + open_edit_match = re.search( + r"async function openKanbanEdit\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert open_edit_match, "openKanbanEdit() not found" + open_edit_body = open_edit_match.group(1) + assert "_kanbanTaskModalInitialDisplayedStatus" in open_edit_body, ( + "openKanbanEdit must record the initial displayed status." + ) + assert "_kanbanEditableStatusFor(task.status)" in open_edit_body + + # 3. Submit's edit branch only sends status when it differs from the + # initial displayed value. + submit_match = re.search( + r"async function submitKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert submit_match + submit_body = submit_match.group(1) + assert "statusVal !== _kanbanTaskModalInitialDisplayedStatus" in submit_body, ( + "Edit submit must skip `status` in the payload when the dropdown's " + "displayed value is unchanged — otherwise running/blocked/done/archived " + "tasks get silently demoted on save." + ) + + # 4. openKanbanCreate explicitly nulls the tracker (create always sends). + create_match = re.search( + r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert create_match + create_body = create_match.group(1) + assert "_kanbanTaskModalInitialDisplayedStatus = null" in create_body, ( + "openKanbanCreate must reset the tracker to null so create-mode " + "submits always include status in the POST payload." + ) + + # 5. closeKanbanTaskModal clears the tracker so a stale value can't leak + # into the next open. + close_match = re.search( + r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert close_match + close_body = close_match.group(1) + assert "_kanbanTaskModalInitialDisplayedStatus = null" in close_body + + def test_kanban_assignee_dropdown_uses_select_not_freetext(): """Assignee must be a ` → `` for non-editable original states (running/blocked/done/archived → mapped to `triage` in the dropdown). The tracker fix makes untouched-submit harmless, but a small visual hint like "(real status: running)" would reduce user confusion. + ## [Unreleased] ### Fixed