Merge pull request #1983 from nesquena/stage-328

release: v0.51.34 — Release J (#1979 zh-Hant kanban i18n + #1981 kanban edit/dispatch/assignee)
This commit is contained in:
nesquena-hermes 2026-05-09 14:16:47 -07:00 committed by GitHub
commit e4a9c5b7f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 893 additions and 55 deletions

View file

@ -1,5 +1,50 @@
# Hermes Web UI -- Changelog
## [v0.51.34] — 2026-05-09 — Release J (kanban edit/dispatch + zh-Hant kanban i18n)
### Added
- **PR #1981** by @nesquena-hermes — Three connected Kanban-UX fixes that were load-bearing for the actual work-queue lifecycle:
- **Edit task** — new `.kanban-edit-btn` on the detail-view header opens the existing `#kanbanTaskModal` pre-filled from a fresh server fetch. Submit branches POST→PATCH for edit mode. Backend already supported `_patch_task` at `api/kanban_bridge.py:338-424`; pure UI gap closed.
- **Run dispatcher** — new `runKanbanDispatcher()` posts `/api/kanban/dispatch` WITHOUT `dry_run=1` after a `showConfirmDialog`. Two UI surfaces: lightning-bolt button in the board header and primary "Run dispatcher" button in the sidebar bulk bar. `_kanbanFormatDispatchResult()` produces concrete summaries (`Dispatched: 1 spawned, 2 skipped (no assignee)`) instead of a generic OK toast. Existing `nudgeKanbanDispatcher()` preserved as the dry-run preview path.
- **Assignee dropdown** — `<input list>` → `<select>` populated from `/api/profiles` (Hermes profile names) + historical board assignees (under `<optgroup label="Other">`) + explicit "— Unassigned (won't auto-run) —" option. Helper text under the field explains the dispatcher claim contract. Soft warning if the user picks Ready + Unassigned (proceeds on second submit).
Side effect: default new-task status changed from `triage` → `ready` so the dispatcher actually picks up newly created tasks without an extra status change. Improvements to `.kanban-modal-error` styling benefit the existing create-board modal too.
**Stage-328 hotfix per nesquena's pre-merge review:** caught a destructive edit-mode regression — opening Edit on a `running`/`blocked`/`done`/`archived` task and saving without changing the status would silently demote the task to `triage` (because `_kanbanEditableStatusFor()` maps non-editable originals to `'triage'` for the dropdown display, and `submitKanbanTaskModal()` was unconditionally including the dropdown value in the PATCH payload). Fixed in commit `8e0eedd1` by introducing a module-scoped `_kanbanTaskModalInitialDisplayedStatus` tracker that records the dropdown value at modal open; the submit path only includes `status` when the user has actually changed it from the displayed value. Added `tests/test_kanban_ui_static.py::test_kanban_edit_mode_preserves_status_when_dropdown_untouched` pinning the invariant.
19 new i18n keys × 8 locales = 152 entries (zh-Hant added in stage augmentation, see below). 4 new regression tests.
Closes #1982.
### Fixed
- **PR #1979** by @Michaelyklam — Backfilled the previously-empty zh-Hant kanban locale block in `static/i18n.js`. The Traditional Chinese locale never had Kanban keys at all, so Traditional Chinese users saw English fallbacks for every Kanban label since the panel shipped. Now zh-Hant has 68 kanban keys at parity with the other 7 supported locales (en/ja/ru/es/de/zh/pt/ko). Closes #1972.
**Stage augmentation (`3fbecc48`):** when #1981 added 17 NEW kanban keys for the edit/run/assignee work, those went into the 8 existing kanban-supporting locales but missed zh-Hant again (since #1981 was authored before #1979 landed). Stage-328 added a maintainer commit backfilling the 17 new keys into zh-Hant with Traditional Chinese translations adapted from the Simplified Chinese (zh) versions. Result: every locale now has the same 85 kanban keys — zero gap.
### Tests
5043 → **5049 collected, 5049 passing, 0 regressions** (+6 net new from #1981's 4 + nesquena's status-preservation regression + the augmentation parity guard). Full suite ~145 s on Python 3.11 (HERMES_HOME isolated). One known-flake (`test_parallel_session_switch.py::TestGitInfoParallel::test_parallel_faster_than_serial` — timing benchmark that re-passes 3/3 in isolation, see existing flake history).
### Pre-release verification
- Full pytest under `HERMES_HOME` isolation: **5049 passed, 8 skipped, 1 xfailed, 2 xpassed** in 145.20 s; one timing-flake re-passes in isolation.
- JS syntax check (`node -c`) clean on `static/i18n.js` + `static/panels.js` (the 2 modified static files).
- Conflict-marker scan: clean.
- Silent-revert check: per-file additions match between contributor branches and stage HEAD.
- Independent reviews: nesquena APPROVED on #1981 with end-to-end audit; #1979 qualifies for self-review per project policy (i18n.js only, CI green on 3.11/3.12/3.13).
- Opus advisor: SHIP-WITH-FIXES (all required code-correctness items pass; the "fixes" were CHANGELOG entries to add — applied here).
### Follow-up items filed (non-blocking)
Three nice-to-have polish items called out by Opus that don't block this release:
- **`_kanbanIsDispatching` flag** to disable the Run/Preview buttons during in-flight POST (current double-click path is benign — atomic `claim_task` server-side prevents destructive double-spawn — but produces a "0 spawned" second toast).
- **Profile-cache invalidation hook** for `_kanbanProfileNamesCache` so profile create/delete from elsewhere in the WebUI propagates without a reload. Current behavior is graceful degradation (orphaned-profile assignee → dispatcher logs `skipped_nonspawnable`, user can re-edit).
- **Status-display hint** near the modal status `<select>` 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

View file

@ -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',
@ -6392,6 +6494,92 @@ 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: '已封存',
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: '崩潰',
new_conversation: '新對話',
filter_conversations: '篩選對話',
scheduled_jobs: '排程任務',
@ -7480,6 +7668,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 +8634,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',

View file

@ -160,7 +160,8 @@
<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="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">Preview dispatcher</button>
<button class="btn secondary" onclick="nudgeKanbanDispatcher()" data-i18n="kanban_nudge_dispatcher" title="Dry-run: shows what would be claimed without spawning workers">Preview</button>
<button class="btn primary" onclick="runKanbanDispatcher()" data-i18n="kanban_run_dispatcher" title="Claims Ready tasks and spawns worker subprocesses">Run 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()">
@ -718,7 +719,8 @@
</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="Preview dispatcher" data-i18n-title="kanban_nudge_dispatcher" aria-label="Preview dispatcher">▶</button>
<button class="panel-head-btn has-tooltip has-tooltip--bottom" id="btnKanbanPreviewDispatcher" onclick="nudgeKanbanDispatcher()" data-tooltip="Preview dispatcher (dry-run)" data-i18n-title="kanban_nudge_dispatcher" aria-label="Preview dispatcher (dry-run)">▶</button>
<button class="panel-head-btn has-tooltip has-tooltip--bottom kanban-run-dispatch-btn" id="btnKanbanRunDispatcher" onclick="runKanbanDispatcher()" data-tooltip="Run dispatcher — claim Ready tasks" data-i18n-title="kanban_run_dispatcher" aria-label="Run dispatcher"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/></svg></button>
</div>
</div>
<div class="kanban-task-preview" id="kanbanTaskPreview" style="display:none"></div>
@ -1290,8 +1292,10 @@
</div>
<div class="kanban-modal-row">
<label for="kanbanTaskModalAssignee" data-i18n="kanban_assignee">Assignee</label>
<input type="text" id="kanbanTaskModalAssignee" list="kanbanTaskModalAssigneeList" maxlength="64" autocomplete="off" data-i18n-placeholder="kanban_assignee_placeholder" placeholder="Optional — leave blank for any worker">
<datalist id="kanbanTaskModalAssigneeList"></datalist>
<select id="kanbanTaskModalAssignee">
<!-- Options populated by _kanbanPopulateAssigneeSelect() at modal open time. -->
</select>
<div class="kanban-modal-hint" id="kanbanTaskModalAssigneeHint" data-i18n="kanban_assignee_hint">Pick a Hermes profile so the dispatcher can claim and run this task. Tasks left as <em>Unassigned</em> will sit in Ready forever.</div>
</div>
<div class="kanban-modal-row">
<label for="kanbanTaskModalTenant" data-i18n="kanban_tenant">Tenant</label>

View file

@ -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 <assignee>` 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,240 @@ 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 </body>.
//
// 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
// 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.
// 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 += `<optgroup label="${esc(t('kanban_assignee_profiles_label') || 'Hermes profiles')}">`;
html += profiles.map(v => `<option value="${esc(v)}"${v === currentValue ? ' selected' : ''}>${esc(v)}</option>`).join('');
html += '</optgroup>';
}
if (extras.length) {
html += `<optgroup label="${esc(t('kanban_assignee_other_label') || 'Other (CLI lanes / removed profiles)')}">`;
html += extras.map(v => `<option value="${esc(v)}"${v === currentValue ? ' selected' : ''}>${esc(v)}</option>`).join('');
html += '</optgroup>';
}
// Final "no assignee" fallthrough — explicit so users know what they're choosing.
html += `<option value=""${(!currentValue) ? ' selected' : ''}>${esc(t('kanban_assignee_unassigned') || '— Unassigned (won\u2019t auto-run) —')}</option>`;
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 => `<option value="${esc(v)}"></option>`).join('');
if (tList) tList.innerHTML = tenants.map(v => `<option value="${esc(v)}"></option>`).join('');
_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
// 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;
// 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: initialDisplayedStatus,
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 <select> populated from /api/profiles + board history; setting
// .value before the options exist would silently fail.
set('kanbanTaskModalTenant', v.tenant || '');
set('kanbanTaskModalPriority', v.priority != null ? v.priority : 0);
const errEl = document.getElementById('kanbanTaskModalError');
if (errEl) { errEl.textContent = ''; delete errEl.dataset.warningShown; }
const submitBtn = document.getElementById('kanbanTaskModalSubmit');
if (submitBtn) submitBtn.disabled = false;
}
function _kanbanSetTaskModalLabels(mode){
const titleH = document.getElementById('kanbanTaskModalTitle');
const submitBtn = document.getElementById('kanbanTaskModalSubmit');
if (mode === 'edit') {
if (titleH) titleH.textContent = t('kanban_edit_task') || 'Edit task';
if (submitBtn) submitBtn.textContent = t('save') || 'Save';
} else {
if (titleH) titleH.textContent = t('kanban_new_task') || 'New task';
if (submitBtn) submitBtn.textContent = t('create') || 'Create';
}
}
function _kanbanPopulateTenantDatalist(){
const tenants = (_kanbanBoard && Array.isArray(_kanbanBoard.tenants)) ? _kanbanBoard.tenants : [];
const tList = document.getElementById('kanbanTaskModalTenantList');
if (tList) tList.innerHTML = tenants.map(v => `<option value="${esc(v)}"></option>`).join('');
}
function closeKanbanTaskModal(){
const modal = document.getElementById('kanbanTaskModal');
if (modal) modal.hidden = true;
_kanbanTaskModalMode = 'create';
_kanbanTaskModalEditingId = null;
_kanbanTaskModalInitialDisplayedStatus = null;
document.removeEventListener('keydown', _kanbanTaskModalKey);
}
@ -1675,28 +1928,75 @@ async function submitKanbanTaskModal(){
if (titleEl) titleEl.focus();
return;
}
// Build payload — only include fields the user actually filled in so the
// backend can apply its own defaults rather than us forcing empty strings.
// Build payload — for create we omit defaulted fields so the backend chooses;
// for edit we send every field so users can clear assignee/tenant/body.
const isEdit = _kanbanTaskModalMode === 'edit';
const payload = {title};
if (bodyEl && bodyEl.value.trim()) payload.body = bodyEl.value;
if (statusEl && statusEl.value) payload.status = statusEl.value;
if (assigneeEl && assigneeEl.value.trim()) payload.assignee = assigneeEl.value.trim();
if (tenantEl && tenantEl.value.trim()) payload.tenant = tenantEl.value.trim();
if (priorityEl && priorityEl.value !== '' && priorityEl.value !== '0') {
const n = parseInt(priorityEl.value, 10);
if (!Number.isNaN(n)) payload.priority = n;
const bodyVal = bodyEl ? bodyEl.value : '';
const assigneeVal = assigneeEl ? assigneeEl.value.trim() : '';
const tenantVal = tenantEl ? tenantEl.value.trim() : '';
const statusVal = statusEl ? statusEl.value : '';
const priorityRaw = priorityEl ? priorityEl.value : '';
if (isEdit) {
payload.body = bodyVal;
payload.assignee = assigneeVal || null;
payload.tenant = tenantVal || null;
// 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 {
if (bodyVal.trim()) payload.body = bodyVal;
if (statusVal) payload.status = statusVal;
if (assigneeVal) payload.assignee = assigneeVal;
if (tenantVal) payload.tenant = tenantVal;
if (priorityRaw !== '' && priorityRaw !== '0') {
const n = parseInt(priorityRaw, 10);
if (!Number.isNaN(n)) payload.priority = n;
}
}
// Soft warning: a Ready task with the explicit "Unassigned" option will sit
// forever because the dispatcher skips unassigned rows (kanban_db.py:3567).
// The dropdown now makes this an explicit choice (the user picked "—
// Unassigned (won't auto-run) —"), but we still surface a one-time confirm
// so they don't lose work to a typo.
if (statusVal === 'ready' && !assigneeVal) {
if (errEl && !errEl.dataset.warningShown) {
errEl.textContent = t('kanban_ready_needs_assignee')
|| 'You picked Unassigned + Ready. The dispatcher will skip this task. Submit again to confirm, or pick a profile.';
errEl.dataset.warningShown = '1';
const sel = document.getElementById('kanbanTaskModalAssignee');
if (sel) sel.focus();
return;
}
}
if (submitBtn) submitBtn.disabled = true;
if (errEl) errEl.textContent = '';
if (errEl) { errEl.textContent = ''; delete errEl.dataset.warningShown; }
try {
const created = await api('/api/kanban/tasks' + _kanbanBoardQuery(), {
method: 'POST',
body: JSON.stringify(payload),
});
let saved;
if (isEdit && _kanbanTaskModalEditingId) {
saved = await api(
'/api/kanban/tasks/' + encodeURIComponent(_kanbanTaskModalEditingId) + _kanbanBoardQuery(),
{method: 'PATCH', body: JSON.stringify(payload)},
);
} else {
saved = await api('/api/kanban/tasks' + _kanbanBoardQuery(), {
method: 'POST',
body: JSON.stringify(payload),
});
}
closeKanbanTaskModal();
await loadKanban(true);
if (created && created.task && created.task.id) {
await loadKanbanTask(created.task.id);
const savedId = saved && saved.task && saved.task.id;
if (savedId) {
await loadKanbanTask(savedId);
} else if (isEdit && _kanbanTaskModalEditingId) {
await loadKanbanTask(_kanbanTaskModalEditingId);
}
} catch(e) {
if (errEl) errEl.textContent = (e.message || String(e));
@ -1751,6 +2051,7 @@ function _kanbanRenderTaskDetail(data){
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>
<button class="btn secondary kanban-edit-btn" onclick="openKanbanEdit('${esc(task.id)}')" data-i18n="kanban_edit_task" title="${esc(t('kanban_edit_task') || 'Edit task')}">${esc(t('kanban_edit_task') || 'Edit task')}</button>
</div>
<div class="kanban-task-preview-body">${esc(body)}</div>
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}

View file

@ -3490,12 +3490,29 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-modal-row{margin-bottom:10px;}
.kanban-modal-row label{display:block;font-size:11px;color:var(--muted);margin-bottom:4px;font-weight:500;}
.kanban-modal-row input[type="text"],
.kanban-modal-row input[type="number"],
.kanban-modal-row input[type="color"],
.kanban-modal-row select,
.kanban-modal-row textarea{
width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;
color:var(--text);padding:8px 10px;font:inherit;font-size:13px;box-sizing:border-box;
}
/* Native select chevron — make sure the dropdown reads as a dropdown, not as
a flat text input. Custom SVG arrow + appearance:none for cross-browser
consistency in dark theme. */
.kanban-modal-row select{
-webkit-appearance:none;-moz-appearance:none;appearance:none;
padding-right:32px;cursor:pointer;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2390a0b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat:no-repeat;
background-position:right 8px center;
background-size:16px;
}
.kanban-modal-row select:focus{
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23FFD700' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
}
.kanban-modal-row input:focus,
.kanban-modal-row select:focus,
.kanban-modal-row textarea:focus{
outline:none;border-color:var(--accent, #FFD700);
}
@ -3503,6 +3520,9 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-modal-row input[type="color"]{height:36px;padding:2px;cursor:pointer;}
.kanban-modal-row-inline{display:flex;gap:10px;}
.kanban-modal-row-inline > *{flex:1;min-width:0;}
.kanban-modal-hint{font-size:11px;color:var(--muted);line-height:1.5;margin-top:6px;}
.kanban-modal-hint code{background:var(--input-bg);padding:1px 5px;border-radius:4px;font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:11px;color:var(--text);}
.kanban-modal-hint em{color:var(--text);font-style:normal;font-weight:600;}
.kanban-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;}
.kanban-modal-error{
color:var(--danger,var(--error,#f87171));
@ -3520,7 +3540,21 @@ 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-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-edit-btn{flex-shrink:0;font-size:11px;padding:4px 10px;margin-left:auto;}
.kanban-task-preview-title{flex:1;font-size:14px;font-weight:650;color:var(--text);margin-bottom:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* Run dispatcher button: visually distinct from the dry-run preview ▶ button
sitting next to it so users can tell at a glance which one actually spawns
workers. Uses the accent border + tinted background but keeps the
panel-head sizing so it doesn't stand out as inconsistent. See PR for
#1968 / kanban edit+run gap fix. */
.kanban-run-dispatch-btn{
border-color:color-mix(in srgb,var(--accent,#FFD700) 60%,var(--border));
background:color-mix(in srgb,var(--accent,#FFD700) 14%,transparent);
color:var(--accent,#FFD700);
}
.kanban-run-dispatch-btn:hover{
background:color-mix(in srgb,var(--accent,#FFD700) 24%,transparent);
}
.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;}

View file

@ -129,8 +129,12 @@ def test_kanban_new_task_header_button_opens_modal():
assert open_fn, "openKanbanCreate() not found"
body = open_fn.group(1)
assert "modal.hidden = false" in body
assert "kanbanTaskModalAssigneeList" in body
assert "kanbanTaskModalTenantList" in body
# Assignee is now a <select> populated from /api/profiles + board history,
# tenant is still a free-text <input> backed by a datalist.
assert "_kanbanPopulateAssigneeSelect" in body, (
"openKanbanCreate must populate the assignee <select> from /api/profiles."
)
assert "_kanbanPopulateTenantDatalist" in body
assert "_kanbanTaskModalKey" in body # ESC + Enter handler attached
# 5. closeKanbanTaskModal() hides the modal and unbinds the listener.
@ -181,6 +185,234 @@ def test_kanban_new_task_header_button_opens_modal():
assert "api('/api/kanban/tasks'" in qa_body
def test_kanban_task_detail_has_edit_button_and_modal_supports_edit_mode():
"""The Kanban task detail view must surface an Edit button — the previous
detail view exposed only status-transition buttons (Triage/Todo/Ready/...),
Block/Unblock, and Add comment, with no way to edit the title, body,
assignee, tenant, or priority of a task once created.
Backend supports it (PATCH /api/kanban/tasks/<id> with title/body/assignee/
tenant/priority — see _patch_task in api/kanban_bridge.py); this regression
pins the UI surface.
"""
# 1. _kanbanRenderTaskDetail emits an Edit button wired to openKanbanEdit.
render_match = re.search(
r"function _kanbanRenderTaskDetail\(data\)\{(.*?)\n\}", PANELS, re.DOTALL
)
assert render_match, "_kanbanRenderTaskDetail() not found"
render_body = render_match.group(1)
assert 'class="kanban-edit-btn"' in render_body or "kanban-edit-btn" in render_body, (
"Task detail view must include the Edit button (.kanban-edit-btn)."
)
assert "openKanbanEdit(" in render_body, (
"Edit button must invoke openKanbanEdit(taskId)."
)
# 2. openKanbanEdit() exists and pre-fills the modal from a fetched 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 "/api/kanban/tasks/" in open_edit_body
assert "_kanbanTaskModalMode = 'edit'" in open_edit_body
assert "_kanbanTaskModalEditingId = task.id" in open_edit_body
# 3. submitKanbanTaskModal branches to PATCH for edit, POST for create.
submit_match = re.search(
r"async function submitKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL
)
assert submit_match
submit_body = submit_match.group(1)
assert "method: 'PATCH'" in submit_body, (
"submitKanbanTaskModal must PATCH /api/kanban/tasks/<id> in edit mode."
)
assert "method: 'POST'" in submit_body, "Create path still POSTs."
assert "_kanbanTaskModalEditingId" in submit_body
# Edit-mode title-bar / button labels.
assert "kanban_edit_task" in PANELS
label_match = re.search(
r"function _kanbanSetTaskModalLabels\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL
)
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 <select> 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 <select> for assignee, with a hint span explaining
# the dispatcher claim contract.
sel_idx = INDEX.find('id="kanbanTaskModalAssignee"')
assert sel_idx != -1, "kanbanTaskModalAssignee element not found"
# Walk back to find the opening tag — it must be a <select>, not <input>.
start = INDEX.rfind('<', 0, sel_idx)
tag_open = INDEX[start:sel_idx + 60]
assert tag_open.startswith('<select'), (
f"kanbanTaskModalAssignee must be a <select> 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 "<optgroup" in pop_body
assert 'value=""' in pop_body, (
"Must include the explicit empty 'Unassigned' fallthrough option."
)
# Profile loader hits /api/profiles.
load_match = re.search(
r"async function _kanbanLoadProfileNames\(\)\{(.*?)\n\}", PANELS, re.DOTALL
)
assert load_match
assert "/api/profiles" in load_match.group(1)
def test_kanban_run_dispatcher_button_exists_and_is_distinct_from_preview():
"""The previous Kanban UI only exposed `nudgeKanbanDispatcher()` — a
dry-run preview that never actually spawns workers — leaving users with
no way to run their tasks from the WebUI. There must now be a real
runKanbanDispatcher() entry point AND it must call /api/kanban/dispatch
WITHOUT dry_run=1, and the existing nudge button must still be a dry-run.
"""
# 1. runKanbanDispatcher() exists and dispatches without dry_run.
run_match = re.search(
r"async function runKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL
)
assert run_match, "runKanbanDispatcher() not found"
run_body = run_match.group(1)
assert "/api/kanban/dispatch" in run_body
# The real-run path must NOT contain dry_run=1.
assert "dry_run=1" not in run_body, (
"runKanbanDispatcher() must NOT pass dry_run=1 — that's the preview path."
)
# It MUST go through showConfirmDialog (not window.confirm) because it
# spawns workers — and the existing test_kanban_dashboard_parity_core_controls_are_native
# asserts no window.confirm/prompt calls in panels.js anyway.
assert "showConfirmDialog" in run_body, (
"runKanbanDispatcher() must use showConfirmDialog before spawning workers."
)
# 2. nudgeKanbanDispatcher() (the existing preview path) still uses dry_run=1.
nudge_match = re.search(
r"async function nudgeKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL
)
assert nudge_match
nudge_body = nudge_match.group(1)
assert "dry_run=1" in nudge_body, (
"nudgeKanbanDispatcher() must remain a dry-run preview (dry_run=1)."
)
# 3. The board-header has a button wired to runKanbanDispatcher().
assert 'id="btnKanbanRunDispatcher"' in INDEX
btn_idx = INDEX.find('id="btnKanbanRunDispatcher"')
# Search backward to the opening `<button` and forward to `</button>` to
# capture the full element (class= attribute precedes id= in the markup).
btn_start = INDEX.rfind('<button', 0, btn_idx)
btn_end = INDEX.find('</button>', btn_idx) + len('</button>')
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 (