From aaabd32c924f961e29c0e6f2e2895825ff7c4527 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 24 Jun 2026 20:37:30 -0400 Subject: [PATCH 1/5] feat(#4470): expose skills, max_runtime_seconds, and parents in Kanban create modal --- static/i18n.js | 84 +++++++ static/index.html | 18 +- static/panels.js | 59 ++++- ...est_issue4470_kanban_task_editor_fields.py | 206 ++++++++++++++++++ 4 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 tests/test_issue4470_kanban_task_editor_fields.py diff --git a/static/i18n.js b/static/i18n.js index 53b15afd7..c53074c09 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -953,6 +953,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -2592,6 +2598,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -4222,6 +4234,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -5556,6 +5574,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -7123,6 +7147,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -8668,6 +8698,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -10268,6 +10304,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -12123,6 +12165,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -13641,6 +13689,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -15169,6 +15223,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -16817,6 +16877,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Répertoire', kanban_workspace_path_required: 'Le chemin de l\'espace de travail est requis pour les espaces de travail non-brouillon.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Ajouter une dépendance', kanban_remove_dependency: 'Supprimer la dépendance', kanban_dependency_placeholder: 'ID de la tâche à lier', @@ -18408,6 +18474,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -20053,6 +20125,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Katalog', kanban_workspace_path_required: 'Ścieżka do obszaru roboczego jest wymagana dla obszarów innych niż tymczasowe (scratch).', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Dodaj zależność', kanban_remove_dependency: 'Usuń zależność', kanban_dependency_placeholder: 'Identyfikator zadania do powiązania', @@ -21528,6 +21606,12 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Thư mục', kanban_workspace_path_required: 'Workspace không phải tạm thời cần có đường dẫn.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Thêm phụ thuộc', kanban_remove_dependency: 'Xóa phụ thuộc', kanban_dependency_placeholder: 'ID tác vụ cần liên kết', diff --git a/static/index.html b/static/index.html index 6eb732d43..662439dab 100644 --- a/static/index.html +++ b/static/index.html @@ -1725,6 +1725,7 @@
+
Higher numbers run first. Default 0.
@@ -1749,7 +1750,22 @@
+
+ + +
+
+ + +
Leave empty for unlimited.
+
+
+ + +
diff --git a/static/panels.js b/static/panels.js index 240d66543..59294151d 100644 --- a/static/panels.js +++ b/static/panels.js @@ -2688,6 +2688,8 @@ function openKanbanCreate(){ } }); _kanbanPopulateTenantDatalist(); + _kanbanPopulateWorkspacePathDatalist(); + _kanbanPopulateParentsDatalist(); modal.hidden = false; if (_kanbanTaskModalFocusCleanup) { _kanbanTaskModalFocusCleanup(); @@ -2778,6 +2780,9 @@ function _kanbanResetTaskModalFields(values){ // .value before the options exist would silently fail. set('kanbanTaskModalTenant', v.tenant || ''); set('kanbanTaskModalPriority', v.priority != null ? v.priority : 0); + set('kanbanTaskModalSkills', Array.isArray(v.skills) ? v.skills.join(', ') : (v.skills || '')); + set('kanbanTaskModalMaxRuntimeSeconds', v.max_runtime_seconds != null ? v.max_runtime_seconds : ''); + set('kanbanTaskModalParents', ''); const errEl = document.getElementById('kanbanTaskModalError'); if (errEl) { errEl.textContent = ''; delete errEl.dataset.warningShown; } const submitBtn = document.getElementById('kanbanTaskModalSubmit'); @@ -2787,20 +2792,25 @@ function _kanbanResetTaskModalFields(values){ function _kanbanSetTaskModalLabels(mode){ const titleH = document.getElementById('kanbanTaskModalTitle'); const submitBtn = document.getElementById('kanbanTaskModalSubmit'); - const workspaceKindEl = document.getElementById('kanbanTaskModalWorkspaceKind'); - const workspacePathEl = document.getElementById('kanbanTaskModalWorkspacePath'); if (mode === 'edit') { if (titleH) titleH.textContent = t('kanban_edit_task') || 'Edit task'; if (submitBtn) submitBtn.textContent = t('save') || 'Save'; - // Disable workspace fields during edit since they are not handled by the backend - if (workspaceKindEl) workspaceKindEl.disabled = true; - if (workspacePathEl) workspacePathEl.disabled = true; } else { if (titleH) titleH.textContent = t('kanban_new_task') || 'New task'; if (submitBtn) submitBtn.textContent = t('create') || 'Create'; - // Enable workspace fields during create - if (workspaceKindEl) workspaceKindEl.disabled = false; - if (workspacePathEl) workspacePathEl.disabled = false; + } + // Workspace and new backend fields are create-only; backend patch doesn't handle them. + const createOnlyIds = [ + 'kanbanTaskModalWorkspaceKind', + 'kanbanTaskModalWorkspacePath', + 'kanbanTaskModalSkills', + 'kanbanTaskModalMaxRuntimeSeconds', + 'kanbanTaskModalParents', + ]; + const disabled = mode === 'edit'; + for (const id of createOnlyIds) { + const el = document.getElementById(id); + if (el) el.disabled = disabled; } } @@ -2823,6 +2833,27 @@ function _kanbanPopulateTenantDatalist(){ if (tList) tList.innerHTML = tenants.map(v => ``).join(''); } +function _kanbanPopulateWorkspacePathDatalist(){ + const cols = (_kanbanBoard && _kanbanBoard.columns) || []; + const seen = new Set(); + const opts = []; + for (const col of cols) { + for (const task of (col.tasks || [])) { + const path = task && task.workspace_path; + if (!path || seen.has(path)) continue; + seen.add(path); + opts.push(``); + } + } + const list = document.getElementById('kanbanTaskModalWorkspacePathList'); + if (list) list.innerHTML = opts.join(''); +} + +function _kanbanPopulateParentsDatalist(){ + const list = document.getElementById('kanbanTaskModalParentsList'); + if (list) list.innerHTML = _kanbanLinkableTaskOptions(null); +} + function _trapModalFocus(modalEl){ if (!modalEl) return () => {}; const selector = 'a[href], button, textarea, input, select, summary, [tabindex]:not([tabindex="-1"])'; @@ -2911,6 +2942,9 @@ async function submitKanbanTaskModal(){ const priorityEl = document.getElementById('kanbanTaskModalPriority'); const workspaceKindEl = document.getElementById('kanbanTaskModalWorkspaceKind'); const workspacePathEl = document.getElementById('kanbanTaskModalWorkspacePath'); + const skillsEl = document.getElementById('kanbanTaskModalSkills'); + const maxRuntimeEl = document.getElementById('kanbanTaskModalMaxRuntimeSeconds'); + const parentsEl = document.getElementById('kanbanTaskModalParents'); const errEl = document.getElementById('kanbanTaskModalError'); const submitBtn = document.getElementById('kanbanTaskModalSubmit'); const title = titleEl ? titleEl.value.trim() : ''; @@ -2939,6 +2973,9 @@ async function submitKanbanTaskModal(){ const statusVal = statusEl ? statusEl.value : ''; const priorityRaw = priorityEl ? priorityEl.value : ''; const workspacePathVal = workspacePathEl ? workspacePathEl.value.trim() : ''; + const skillsRaw = skillsEl ? skillsEl.value.trim() : ''; + const maxRuntimeRaw = maxRuntimeEl ? maxRuntimeEl.value.trim() : ''; + const parentsRaw = parentsEl ? parentsEl.value.trim() : ''; if (isEdit) { payload.body = bodyVal; payload.assignee = assigneeVal || null; @@ -2965,6 +3002,12 @@ async function submitKanbanTaskModal(){ } payload.workspace_kind = workspaceKind; if (workspacePathVal) payload.workspace_path = workspacePathVal; + if (skillsRaw) { + payload.skills = skillsRaw.split(',').map(s => s.trim()).filter(Boolean); + } + const maxRuntimeN = maxRuntimeRaw !== '' ? parseInt(maxRuntimeRaw, 10) : NaN; + if (!Number.isNaN(maxRuntimeN) && maxRuntimeN > 0) payload.max_runtime_seconds = maxRuntimeN; + if (parentsRaw) payload.parents = [parentsRaw]; } // Soft warning: a Ready task with the explicit "Unassigned" option will sit // forever because the dispatcher skips unassigned rows (kanban_db.py:3567). diff --git a/tests/test_issue4470_kanban_task_editor_fields.py b/tests/test_issue4470_kanban_task_editor_fields.py new file mode 100644 index 000000000..0b76c4757 --- /dev/null +++ b/tests/test_issue4470_kanban_task_editor_fields.py @@ -0,0 +1,206 @@ +"""Source-inspection and behavioral tests for Kanban modal fields added in #4470.""" +from __future__ import annotations +import json +import re +import shutil +import subprocess +from pathlib import Path +import pytest + +ROOT = Path(__file__).resolve().parents[1] +INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") +PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") +KANBAN_BRIDGE = (ROOT / "api" / "kanban_bridge.py").read_text(encoding="utf-8") + +NODE = shutil.which("node") + + +def test_modal_html_exposes_new_fields(): + for field_id in ( + "kanbanTaskModalSkills", + "kanbanTaskModalMaxRuntimeSeconds", + "kanbanTaskModalParents", + "kanbanTaskModalParentsList", + "kanbanTaskModalWorkspacePathList", + "kanbanTaskModalPriorityHint", + ): + assert f'id="{field_id}"' in INDEX_HTML, f"Missing: {field_id}" + assert 'list="kanbanTaskModalParentsList"' in INDEX_HTML + assert 'list="kanbanTaskModalWorkspacePathList"' in INDEX_HTML + assert 'data-i18n="kanban_priority_hint"' in INDEX_HTML + + +def _submit_body(): + m = re.search( + r"async function submitKanbanTaskModal\(\)\s*\{(.*?)\n\}", + PANELS_JS, re.DOTALL, + ) + assert m, "submitKanbanTaskModal() not found" + return m.group(1) + + +def test_submit_reads_and_sends_skills_as_array(): + body = _submit_body() + assert "kanbanTaskModalSkills" in body + assert "payload.skills" in body + assert ".split(',')" in body or ".split(', ')" in body + + +def test_submit_reads_and_sends_max_runtime_as_integer(): + body = _submit_body() + assert "kanbanTaskModalMaxRuntimeSeconds" in body + assert "payload.max_runtime_seconds" in body + assert "parseInt" in body + + +def test_submit_reads_and_sends_parents_as_list(): + body = _submit_body() + assert "kanbanTaskModalParents" in body + assert "payload.parents" in body + assert "[parentsRaw]" in body or "payload.parents = [" in body + + +def test_new_fields_only_in_create_branch(): + body = _submit_body() + edit_match = re.search(r"if \(isEdit\) \{(.*?)\} else \{", body, re.DOTALL) + assert edit_match, "isEdit...else block not found" + edit_branch = edit_match.group(1) + assert "payload.skills" not in edit_branch + assert "payload.max_runtime_seconds" not in edit_branch + assert "payload.parents" not in edit_branch + + +def test_reset_handler_covers_new_fields(): + m = re.search( + r"function _kanbanResetTaskModalFields\([^)]*\)\s*\{(.*?)\n\}", + PANELS_JS, re.DOTALL, + ) + assert m, "_kanbanResetTaskModalFields() not found" + body = m.group(1) + assert "kanbanTaskModalSkills" in body + assert "kanbanTaskModalMaxRuntimeSeconds" in body + assert "kanbanTaskModalParents" in body + + +def test_labels_handler_disables_new_fields_in_edit(): + m = re.search( + r"function _kanbanSetTaskModalLabels\([^)]*\)\s*\{(.*?)\n\}", + PANELS_JS, re.DOTALL, + ) + assert m, "_kanbanSetTaskModalLabels() not found" + body = m.group(1) + assert "kanbanTaskModalSkills" in body + assert "kanbanTaskModalMaxRuntimeSeconds" in body + assert "kanbanTaskModalParents" in body + assert "disabled" in body + + +def test_datalist_helpers_exist_and_are_called(): + fn_wp = "function _kanbanPopulateWorkspacePathDatalist" + fn_par = "function _kanbanPopulateParentsDatalist" + assert fn_wp in PANELS_JS + assert "kanbanTaskModalWorkspacePathList" in PANELS_JS + # Check that workspace_path appears inside the function definition body + fn_start = PANELS_JS.find(fn_wp) + assert "workspace_path" in PANELS_JS[fn_start:fn_start + 500], \ + "workspace_path not found in _kanbanPopulateWorkspacePathDatalist body" + assert fn_par in PANELS_JS + assert "_kanbanLinkableTaskOptions(null)" in PANELS_JS + + m = re.search(r"function openKanbanCreate\(\)\s*\{(.*?)\n\}", PANELS_JS, re.DOTALL) + assert m, "openKanbanCreate() not found" + body = m.group(1) + assert "_kanbanPopulateWorkspacePathDatalist()" in body + assert "_kanbanPopulateParentsDatalist()" in body + + +def test_i18n_new_keys_in_all_locales(): + required = [ + "kanban_skills", + "kanban_skills_placeholder", + "kanban_max_runtime_seconds", + "kanban_max_runtime_hint", + "kanban_parents_modal", + "kanban_parents_placeholder", + ] + for key in required: + count = I18N_JS.count(f"{key}:") + assert count == 14, f"Key '{key}' appears {count} times, expected 14 (one per locale)" + + +def test_backend_already_accepts_new_fields(): + assert "skills=body.get(" in KANBAN_BRIDGE + assert "max_runtime_seconds=body.get(" in KANBAN_BRIDGE + assert "parents=body.get(" in KANBAN_BRIDGE + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_submit_payload_behavioral(): + """Node.js execution: extract submitKanbanTaskModal and verify payload construction.""" + # Find the full function body + fn_start = PANELS_JS.find("async function submitKanbanTaskModal(") + assert fn_start >= 0, "submitKanbanTaskModal not found" + brace_start = PANELS_JS.index("{", fn_start) + depth = 0 + fn_end = brace_start + for i in range(brace_start, len(PANELS_JS)): + if PANELS_JS[i] == "{": + depth += 1 + elif PANELS_JS[i] == "}": + depth -= 1 + if depth == 0: + fn_end = i + 1 + break + fn_body = PANELS_JS[fn_start:fn_end] + + harness = ( + "// Stub minimal DOM\n" + "const noop = () => {};\n" + "const elements = {\n" + " kanbanTaskModalTitleInput: { value: 'Test Task', focus: noop },\n" + " kanbanTaskModalBody: { value: '' },\n" + " kanbanTaskModalStatus: { value: 'triage' },\n" + " kanbanTaskModalAssignee: { value: 'agent1', focus: noop },\n" + " kanbanTaskModalTenant: { value: '' },\n" + " kanbanTaskModalPriority: { value: '0' },\n" + " kanbanTaskModalWorkspaceKind: { value: 'scratch', disabled: false },\n" + " kanbanTaskModalWorkspacePath: { value: '', disabled: false },\n" + " kanbanTaskModalSkills: { value: 'python, git', disabled: false },\n" + " kanbanTaskModalMaxRuntimeSeconds: { value: '120', disabled: false },\n" + " kanbanTaskModalParents: { value: 't_abc', disabled: false },\n" + " kanbanTaskModalError: { textContent: '', dataset: {} },\n" + " kanbanTaskModalSubmit: { disabled: false },\n" + "};\n" + "global.document = { getElementById: (id) => elements[id] || null };\n" + "function t(k) { return k; }\n" + "let capturedPayload = null;\n" + "async function api(url, opts) {\n" + " capturedPayload = opts ? JSON.parse(opts.body) : null;\n" + " return { task: { id: 't_new' } };\n" + "}\n" + "async function loadKanban() {}\n" + "async function loadKanbanTask() {}\n" + "function _kanbanBoardQuery() { return ''; }\n" + "function closeKanbanTaskModal() {}\n" + "let _kanbanTaskModalMode = 'create';\n" + "let _kanbanTaskModalEditingId = null;\n" + "let _kanbanTaskModalInitialDisplayedStatus = null;\n" + + fn_body + "\n" + "submitKanbanTaskModal().then(() => {\n" + " console.log(JSON.stringify(capturedPayload));\n" + "}).catch(e => { process.stderr.write(String(e)); process.exit(1); });\n" + ) + + result = subprocess.run( + [NODE, "-e", harness], + capture_output=True, text=True, timeout=15, + ) + assert result.returncode == 0, f"Node error: {result.stderr}" + output = result.stdout.strip() + assert output, f"No payload captured. stderr: {result.stderr}" + payload = json.loads(output) + + assert payload.get("skills") == ["python", "git"], f"skills wrong: {payload.get('skills')}" + assert payload.get("max_runtime_seconds") == 120, f"max_runtime wrong: {payload.get('max_runtime_seconds')}" + assert payload.get("parents") == ["t_abc"], f"parents wrong: {payload.get('parents')}" From 396dd37891342602cae53879c920eb4308f9a854 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 24 Jun 2026 20:43:54 -0400 Subject: [PATCH 2/5] test(#4470): update workspace-disabled assertion to match createOnlyIds pattern --- tests/test_issue3797_kanban_cli_parity.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_issue3797_kanban_cli_parity.py b/tests/test_issue3797_kanban_cli_parity.py index ff87df7c5..ca1fd9f21 100644 --- a/tests/test_issue3797_kanban_cli_parity.py +++ b/tests/test_issue3797_kanban_cli_parity.py @@ -90,8 +90,9 @@ class TestWorkspacePathValidation: """Modal must disable workspace fields when editing to prevent confusion.""" # The _kanbanSetTaskModalLabels function must disable fields during edit assert "_kanbanSetTaskModalLabels" in PANELS_JS - assert "workspaceKindEl.disabled = true" in PANELS_JS - assert "workspacePathEl.disabled = true" in PANELS_JS + assert "kanbanTaskModalWorkspaceKind" in PANELS_JS + assert "kanbanTaskModalWorkspacePath" in PANELS_JS + assert "el.disabled = disabled" in PANELS_JS class TestDependencyControls: From 7b96b934595db3ac1b1c30c00dc943aa2f5d8b75 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 24 Jun 2026 20:52:05 -0400 Subject: [PATCH 3/5] test(#4470): add kanban_priority_hint to i18n completeness check --- tests/test_issue4470_kanban_task_editor_fields.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_issue4470_kanban_task_editor_fields.py b/tests/test_issue4470_kanban_task_editor_fields.py index 0b76c4757..27d812f23 100644 --- a/tests/test_issue4470_kanban_task_editor_fields.py +++ b/tests/test_issue4470_kanban_task_editor_fields.py @@ -117,6 +117,7 @@ def test_datalist_helpers_exist_and_are_called(): def test_i18n_new_keys_in_all_locales(): required = [ + "kanban_priority_hint", "kanban_skills", "kanban_skills_placeholder", "kanban_max_runtime_seconds", From efa3ebaac4b6fdffee456f246aae56939867d0cc Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Mon, 29 Jun 2026 15:12:45 -0400 Subject: [PATCH 4/5] fix(#4470): reject ambiguous kanban runtime input --- static/i18n.js | 14 +++ static/panels.js | 11 ++- ...est_issue4470_kanban_task_editor_fields.py | 96 ++++++++++++++----- 3 files changed, 97 insertions(+), 24 deletions(-) diff --git a/static/i18n.js b/static/i18n.js index c53074c09..bc259e7b8 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -957,6 +957,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', @@ -2602,6 +2603,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', @@ -4238,6 +4240,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', @@ -5578,6 +5581,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', @@ -7151,6 +7155,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', @@ -8702,6 +8707,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', @@ -10308,6 +10314,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', @@ -12169,6 +12176,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', @@ -13693,6 +13701,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', @@ -15227,6 +15236,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', @@ -16881,6 +16891,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Ajouter une dépendance', @@ -18478,6 +18489,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', @@ -20129,6 +20141,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Dodaj zależność', @@ -21610,6 +21623,7 @@ const LOCALES = { kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', kanban_max_runtime_seconds: 'Max runtime (seconds)', kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', kanban_parents_modal: 'Parent dependencies', kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Thêm phụ thuộc', diff --git a/static/panels.js b/static/panels.js index 59294151d..8316448f0 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3005,8 +3005,15 @@ async function submitKanbanTaskModal(){ if (skillsRaw) { payload.skills = skillsRaw.split(',').map(s => s.trim()).filter(Boolean); } - const maxRuntimeN = maxRuntimeRaw !== '' ? parseInt(maxRuntimeRaw, 10) : NaN; - if (!Number.isNaN(maxRuntimeN) && maxRuntimeN > 0) payload.max_runtime_seconds = maxRuntimeN; + if (maxRuntimeRaw) { + if (!/^[1-9]\d*$/.test(maxRuntimeRaw)) { + if (errEl) errEl.textContent = t('kanban_max_runtime_invalid') + || 'Max runtime must be a whole number of seconds greater than 0.'; + if (maxRuntimeEl) maxRuntimeEl.focus(); + return; + } + payload.max_runtime_seconds = Number(maxRuntimeRaw); + } if (parentsRaw) payload.parents = [parentsRaw]; } // Soft warning: a Ready task with the explicit "Unassigned" option will sit diff --git a/tests/test_issue4470_kanban_task_editor_fields.py b/tests/test_issue4470_kanban_task_editor_fields.py index 27d812f23..66ba14fdd 100644 --- a/tests/test_issue4470_kanban_task_editor_fields.py +++ b/tests/test_issue4470_kanban_task_editor_fields.py @@ -51,7 +51,9 @@ def test_submit_reads_and_sends_max_runtime_as_integer(): body = _submit_body() assert "kanbanTaskModalMaxRuntimeSeconds" in body assert "payload.max_runtime_seconds" in body - assert "parseInt" in body + assert "/^[1-9]\\d*$/" in body + assert "Number(maxRuntimeRaw)" in body + assert "kanban_max_runtime_invalid" in body def test_submit_reads_and_sends_parents_as_list(): @@ -122,6 +124,7 @@ def test_i18n_new_keys_in_all_locales(): "kanban_skills_placeholder", "kanban_max_runtime_seconds", "kanban_max_runtime_hint", + "kanban_max_runtime_invalid", "kanban_parents_modal", "kanban_parents_placeholder", ] @@ -137,9 +140,8 @@ def test_backend_already_accepts_new_fields(): @pytest.mark.skipif(shutil.which("node") is None, reason="node not available") -def test_submit_payload_behavioral(): +def test_submit_payload_behavioral_and_validation(): """Node.js execution: extract submitKanbanTaskModal and verify payload construction.""" - # Find the full function body fn_start = PANELS_JS.find("async function submitKanbanTaskModal(") assert fn_start >= 0, "submitKanbanTaskModal not found" brace_start = PANELS_JS.index("{", fn_start) @@ -157,19 +159,22 @@ def test_submit_payload_behavioral(): harness = ( "// Stub minimal DOM\n" - "const noop = () => {};\n" + "let lastFocused = null;\n" + "function field(id, value = '') {\n" + " return { value, disabled: false, focus() { lastFocused = id; } };\n" + "}\n" "const elements = {\n" - " kanbanTaskModalTitleInput: { value: 'Test Task', focus: noop },\n" - " kanbanTaskModalBody: { value: '' },\n" - " kanbanTaskModalStatus: { value: 'triage' },\n" - " kanbanTaskModalAssignee: { value: 'agent1', focus: noop },\n" - " kanbanTaskModalTenant: { value: '' },\n" - " kanbanTaskModalPriority: { value: '0' },\n" - " kanbanTaskModalWorkspaceKind: { value: 'scratch', disabled: false },\n" - " kanbanTaskModalWorkspacePath: { value: '', disabled: false },\n" - " kanbanTaskModalSkills: { value: 'python, git', disabled: false },\n" - " kanbanTaskModalMaxRuntimeSeconds: { value: '120', disabled: false },\n" - " kanbanTaskModalParents: { value: 't_abc', disabled: false },\n" + " kanbanTaskModalTitleInput: field('kanbanTaskModalTitleInput', 'Test Task'),\n" + " kanbanTaskModalBody: field('kanbanTaskModalBody', ''),\n" + " kanbanTaskModalStatus: field('kanbanTaskModalStatus', 'triage'),\n" + " kanbanTaskModalAssignee: field('kanbanTaskModalAssignee', 'agent1'),\n" + " kanbanTaskModalTenant: field('kanbanTaskModalTenant', ''),\n" + " kanbanTaskModalPriority: field('kanbanTaskModalPriority', '0'),\n" + " kanbanTaskModalWorkspaceKind: field('kanbanTaskModalWorkspaceKind', 'scratch'),\n" + " kanbanTaskModalWorkspacePath: field('kanbanTaskModalWorkspacePath', ''),\n" + " kanbanTaskModalSkills: field('kanbanTaskModalSkills', 'python, git'),\n" + " kanbanTaskModalMaxRuntimeSeconds: field('kanbanTaskModalMaxRuntimeSeconds', '120'),\n" + " kanbanTaskModalParents: field('kanbanTaskModalParents', 't_abc'),\n" " kanbanTaskModalError: { textContent: '', dataset: {} },\n" " kanbanTaskModalSubmit: { disabled: false },\n" "};\n" @@ -188,9 +193,41 @@ def test_submit_payload_behavioral(): "let _kanbanTaskModalEditingId = null;\n" "let _kanbanTaskModalInitialDisplayedStatus = null;\n" + fn_body + "\n" - "submitKanbanTaskModal().then(() => {\n" - " console.log(JSON.stringify(capturedPayload));\n" - "}).catch(e => { process.stderr.write(String(e)); process.exit(1); });\n" + "function resetForCase(maxRuntimeValue) {\n" + " capturedPayload = null;\n" + " lastFocused = null;\n" + " elements.kanbanTaskModalError.textContent = '';\n" + " delete elements.kanbanTaskModalError.dataset.warningShown;\n" + " elements.kanbanTaskModalSubmit.disabled = false;\n" + " elements.kanbanTaskModalTitleInput.value = 'Test Task';\n" + " elements.kanbanTaskModalBody.value = '';\n" + " elements.kanbanTaskModalStatus.value = 'triage';\n" + " elements.kanbanTaskModalAssignee.value = 'agent1';\n" + " elements.kanbanTaskModalTenant.value = '';\n" + " elements.kanbanTaskModalPriority.value = '0';\n" + " elements.kanbanTaskModalWorkspaceKind.value = 'scratch';\n" + " elements.kanbanTaskModalWorkspacePath.value = '';\n" + " elements.kanbanTaskModalSkills.value = 'python, git';\n" + " elements.kanbanTaskModalMaxRuntimeSeconds.value = maxRuntimeValue;\n" + " elements.kanbanTaskModalParents.value = 't_abc';\n" + "}\n" + "async function runCase(maxRuntimeValue) {\n" + " resetForCase(maxRuntimeValue);\n" + " await submitKanbanTaskModal();\n" + " return {\n" + " payload: capturedPayload,\n" + " error: elements.kanbanTaskModalError.textContent,\n" + " focused: lastFocused,\n" + " submitDisabled: elements.kanbanTaskModalSubmit.disabled,\n" + " };\n" + "}\n" + "(async () => {\n" + " const results = [];\n" + " for (const value of ['120', '', '0', '-5', '1.5', '1e2', 'abc']) {\n" + " results.push(await runCase(value));\n" + " }\n" + " console.log(JSON.stringify(results));\n" + "})().catch(e => { process.stderr.write(String(e)); process.exit(1); });\n" ) result = subprocess.run( @@ -200,8 +237,23 @@ def test_submit_payload_behavioral(): assert result.returncode == 0, f"Node error: {result.stderr}" output = result.stdout.strip() assert output, f"No payload captured. stderr: {result.stderr}" - payload = json.loads(output) + results = json.loads(output) - assert payload.get("skills") == ["python", "git"], f"skills wrong: {payload.get('skills')}" - assert payload.get("max_runtime_seconds") == 120, f"max_runtime wrong: {payload.get('max_runtime_seconds')}" - assert payload.get("parents") == ["t_abc"], f"parents wrong: {payload.get('parents')}" + valid, empty, zero, negative, decimal, scientific, alpha = results + valid_payload = valid["payload"] + assert valid_payload.get("skills") == ["python", "git"], f"skills wrong: {valid_payload.get('skills')}" + assert valid_payload.get("max_runtime_seconds") == 120, f"max_runtime wrong: {valid_payload.get('max_runtime_seconds')}" + assert valid_payload.get("parents") == ["t_abc"], f"parents wrong: {valid_payload.get('parents')}" + assert valid["error"] == "" + assert valid["focused"] is None + + empty_payload = empty["payload"] + assert "max_runtime_seconds" not in empty_payload, f"empty max runtime should be omitted: {empty_payload}" + assert empty["error"] == "" + assert empty["focused"] is None + + for invalid_case in (zero, negative, decimal, scientific, alpha): + assert invalid_case["payload"] is None, f"invalid runtime should not submit: {invalid_case}" + assert invalid_case["error"] == "kanban_max_runtime_invalid", f"wrong validation message: {invalid_case}" + assert invalid_case["focused"] == "kanbanTaskModalMaxRuntimeSeconds", f"wrong focus target: {invalid_case}" + assert invalid_case["submitDisabled"] is False, f"submit button should stay enabled: {invalid_case}" From 93d31ebad5cea676aabfd7b295bcedb615f02cb9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 30 Jun 2026 19:51:22 +0000 Subject: [PATCH 5/5] docs(changelog): Kanban modal skills/max_runtime/parents (#4881) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 165446580..680917401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ ### Fixed +- **The Kanban "New task" modal now exposes skills, max runtime, and parent dependencies.** The WebUI task creator reaches parity with the CLI Kanban fields: a Skills input (comma-separated, e.g. `python,git`), a Max runtime (seconds) field (empty = unlimited), and a Parent dependencies (task ID) field. Max-runtime input is validated (`/^[1-9]\d*$/`) with an inline localized error so a junk value can't silently become 1 or unlimited; all fields are XSS-safe. Thanks @rodboev. (#4881, #4470) + - **Mermaid diagrams in chat now have on-diagram zoom / pan / fit / fullscreen controls.** A GitHub-style toolbar (zoom in, zoom out, reset, fit, fullscreen) mounts on each rendered Mermaid diagram so large flowcharts are navigable in place instead of static; zoom is clamped 0.25×–8×, a drag is suppressed from opening the lightbox accidentally while a clean click still opens it, and the Mermaid source is HTML-escaped (no XSS). Thanks @rodboev. (#4871, #4814) - **Mobile titlebar now prioritizes the session title, with tap-to-reveal and long-press actions.** On mobile the titlebar gives the session title priority (ellipsized when long) over surrounding chrome; tapping the title reveals the full title in a popover, and a long-press opens the session actions. Listener-leak-safe (tap wired once per element, outside-click handler cleaned up on dismiss/switch; long-press guarded against double-fire, cancelled on touchmove). Read-only sessions are skipped, and desktop is untouched (touch-gated). Thanks @rodboev. (#4574, #4520)