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:
commit
e4a9c5b7f5
6 changed files with 893 additions and 55 deletions
45
CHANGELOG.md
45
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
222
static/i18n.js
222
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',
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
397
static/panels.js
397
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 <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>` : ''}
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Reference in a new issue