From a0b757a9d44a98a63b0843e5fe01fbca8f796a28 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 10 May 2026 14:18:50 +0800 Subject: [PATCH 01/12] Fix Kanban dispatch double-click race guard --- static/index.html | 6 ++--- static/panels.js | 43 +++++++++++++++++++++++++++------- tests/test_kanban_ui_static.py | 32 +++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/static/index.html b/static/index.html index 343d76bc..9d5b8878 100644 --- a/static/index.html +++ b/static/index.html @@ -160,8 +160,8 @@
- - + +
@@ -719,7 +719,7 @@
- +
diff --git a/static/panels.js b/static/panels.js index 8bdc815a..1f8787ed 100644 --- a/static/panels.js +++ b/static/panels.js @@ -12,6 +12,7 @@ let _kanbanLanesByProfile = false; let _kanbanCurrentBoard = null; let _kanbanBoardsList = null; let _kanbanBoardMenuOpen = false; +let _kanbanIsDispatching = false; // SSE event stream — replaces the 30s polling cadence with a long-lived // /api/kanban/events/stream connection. Falls back to polling when the // EventSource fails to connect (proxy that strips text/event-stream, etc). @@ -1424,11 +1425,14 @@ function _kanbanBoardQuery(extra){ } async function nudgeKanbanDispatcher(){ + if (_kanbanIsDispatching) return; // 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. + _kanbanIsDispatching = true; + _setKanbanDispatcherButtonsDisabled(true); try { const dispatchEndpoint = '/api/kanban/dispatch'; const result = await api( @@ -1437,10 +1441,16 @@ async function nudgeKanbanDispatcher(){ ); showToast(_kanbanFormatDispatchResult(result, true), 'info', 6000); await loadKanban(true); - } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } + } catch(e) { + showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); + } finally { + _kanbanIsDispatching = false; + _setKanbanDispatcherButtonsDisabled(false); + } } async function runKanbanDispatcher(){ + if (_kanbanIsDispatching) return; // Real dispatch: claims Ready tasks and spawns worker subprocesses // (one `hermes -p ` per claimed row, up to max=8 per call). // Confirmation dialog first because this actually consumes API budget on @@ -1450,14 +1460,17 @@ async function runKanbanDispatcher(){ 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; + + _kanbanIsDispatching = true; + _setKanbanDispatcherButtonsDisabled(true); try { + 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; const dispatchEndpoint = '/api/kanban/dispatch'; const result = await api( dispatchEndpoint + '?max=8' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''), @@ -1465,7 +1478,19 @@ async function runKanbanDispatcher(){ ); showToast(_kanbanFormatDispatchResult(result, false), 'info', 8000); await loadKanban(true); - } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } + } catch(e) { + showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); + } finally { + _kanbanIsDispatching = false; + _setKanbanDispatcherButtonsDisabled(false); + } +} + +function _setKanbanDispatcherButtonsDisabled(disabled){ + document.querySelectorAll('.kanban-run-dispatch-btn, .kanban-nudge-dispatch-btn').forEach((btn) => { + btn.disabled = !!disabled; + btn.classList.toggle('disabled', !!disabled); + }); } function _kanbanFormatDispatchResult(result, dryRun){ diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index 5171ac31..38ac42d7 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -413,6 +413,38 @@ def test_kanban_run_dispatcher_button_exists_and_is_distinct_from_preview(): assert token in fmt_body, f"dispatch summary missing field: {token}" +def test_kanban_dispatcher_inflight_guard_prevents_double_click_toast_confusion(): + """Guard against concurrent dispatch invocations in both nudge and real run paths.""" + assert "let _kanbanIsDispatching = false;" in PANELS + assert "function _setKanbanDispatcherButtonsDisabled" in PANELS + + 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 "_kanbanIsDispatching" in run_body, ( + "runKanbanDispatcher() must check or set _kanbanIsDispatching to block concurrent execution." + ) + assert "finally" in run_body, "runKanbanDispatcher() must always clear _kanbanIsDispatching in finally." + assert "_setKanbanDispatcherButtonsDisabled(true)" in run_body, ( + "runKanbanDispatcher() should disable both dispatcher buttons while posting." + ) + assert "_setKanbanDispatcherButtonsDisabled(false)" in run_body, ( + "runKanbanDispatcher() should re-enable dispatcher buttons when done." + ) + + nudge_match = re.search(r"async function nudgeKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL) + assert nudge_match, "nudgeKanbanDispatcher() not found" + nudge_body = nudge_match.group(1) + assert "_kanbanIsDispatching" in nudge_body, ( + "nudgeKanbanDispatcher() should also respect the dispatch in-flight guard." + ) + assert "finally" in nudge_body, "nudgeKanbanDispatcher() should always clear guard in finally." + + assert 'kanban-run-dispatch-btn' in INDEX + assert 'kanban-nudge-dispatch-btn' in INDEX + assert 'btnKanbanRunDispatcher' in INDEX + assert 'btnKanbanPreviewDispatcher' in INDEX + def test_kanban_board_has_native_css_classes(): for selector in ( From 8f077d37f77cfe27db8309ea1e984e9a67be29ce Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 10 May 2026 14:25:08 +0800 Subject: [PATCH 02/12] Fix German profile_skill_count interpolation --- static/i18n.js | 2 +- tests/test_issue1989_profile_skill_count.py | 42 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue1989_profile_skill_count.py diff --git a/static/i18n.js b/static/i18n.js index e1b2c986..ca94dc5e 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -5027,7 +5027,7 @@ const LOCALES = { profile_gateway_stopped: 'Gateway gestoppt', profile_active: 'Aktiv', profile_no_configuration: 'Keine Konfiguration', - profile_skill_count: '{count} Fähigkeiten', + profile_skill_count: (count) => `${count} Fähigkeit${count === 1 ? '' : 'en'}`, profile_use: 'Verwenden', profile_switch_title: 'Profil wechseln', profile_delete_title: 'Profil löschen', diff --git a/tests/test_issue1989_profile_skill_count.py b/tests/test_issue1989_profile_skill_count.py new file mode 100644 index 00000000..1b1641a8 --- /dev/null +++ b/tests/test_issue1989_profile_skill_count.py @@ -0,0 +1,42 @@ +from pathlib import Path +import re + +I18N_JS = (Path(__file__).resolve().parents[1] / "static" / "i18n.js").read_text(encoding="utf-8") + + +def _extract_locale_block(locale: str, src: str) -> str: + locale_key_re = re.compile( + rf"(?m)^[ \t]{{2}}(?:'{re.escape(locale)}'|\"{re.escape(locale)}\"|{re.escape(locale)})\s*:\s*\{{" + ) + start_match = locale_key_re.search(src) + assert start_match is not None, f"Locale {locale!r} not found in i18n.js" + + brace_start = start_match.end() - 1 + assert brace_start != -1, f"Locale {locale!r} block has no opening brace" + + next_locale_re = re.compile( + r"(?m)^[ \t]{2}(?:[A-Za-z]{2,3}(?:[-_][A-Za-z0-9_]+)?|'[A-Za-z]{2,3}(?:[-_][A-Za-z0-9_]+)?'|\"[A-Za-z]{2,3}(?:[-_][A-Za-z0-9_]+)?\")\s*:\s*\{" + ) + next_match = next_locale_re.search(src, pos=brace_start + 1) + end = next_match.start() if next_match else len(src) + + depth = 0 + for idx in range(brace_start, end): + char = src[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return src[brace_start : idx + 1] + assert False, f"Locale {locale!r} block did not close cleanly" + + +def test_german_profile_skill_count_is_function(): + de_block = _extract_locale_block("de", I18N_JS) + # German locale should pass count as an interpolation arg, not expose {count} verbatim. + assert "profile_skill_count:" in de_block + assert "{count} Fähigkeiten" not in de_block + assert re.search(r"profile_skill_count:\s*\([^)]*\)\s*=>", de_block), ( + "profile_skill_count in de locale should be an arrow function, not a string template" + ) From b06eb99d9177cc09fb95b5e49459cb7e53cb14ab Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 10 May 2026 14:37:37 +0800 Subject: [PATCH 03/12] fix(kanban): invalidate profile cache for assignee select --- static/panels.js | 18 ++++++++++++++-- tests/test_kanban_ui_static.py | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/static/panels.js b/static/panels.js index 8bdc815a..1bc3f106 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1679,6 +1679,12 @@ async function createKanbanTask(){ let _kanbanTaskModalMode = 'create'; // 'create' | 'edit' let _kanbanTaskModalEditingId = null; // task id when mode === 'edit' let _kanbanProfileNamesCache = null; // populated lazily on first modal open +let _kanbanProfileNamesCacheAt = 0; +const _KANBAN_PROFILE_NAMES_CACHE_TTL_MS = 30000; +function _invalidateKanbanProfileCache() { + _kanbanProfileNamesCache = null; + _kanbanProfileNamesCacheAt = 0; +} // 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 @@ -1689,9 +1695,13 @@ let _kanbanProfileNamesCache = null; // populated lazily on first modal open let _kanbanTaskModalInitialDisplayedStatus = null; async function _kanbanLoadProfileNames(){ - // Hit /api/profiles once per session and cache; refresh is cheap if needed. + // Hit /api/profiles once per session and cache for a short TTL. // Returns an array of profile names (sorted, default first if present). - if (Array.isArray(_kanbanProfileNamesCache)) return _kanbanProfileNamesCache; + const hasFreshCache = ( + Array.isArray(_kanbanProfileNamesCache) && + (Date.now() - _kanbanProfileNamesCacheAt) < _KANBAN_PROFILE_NAMES_CACHE_TTL_MS + ); + if (hasFreshCache) return _kanbanProfileNamesCache; try { const data = await api('/api/profiles'); const profiles = Array.isArray(data && data.profiles) ? data.profiles : []; @@ -1703,9 +1713,11 @@ async function _kanbanLoadProfileNames(){ return a.localeCompare(b); }); _kanbanProfileNamesCache = names; + _kanbanProfileNamesCacheAt = Date.now(); return names; } catch(_) { _kanbanProfileNamesCache = []; + _kanbanProfileNamesCacheAt = Date.now(); return []; } } @@ -4461,6 +4473,7 @@ async function saveProfileForm(){ if (baseUrl) payload.base_url = baseUrl; if (apiKey) payload.api_key = apiKey; await api('/api/profile/create', { method: 'POST', body: JSON.stringify(payload) }); + _invalidateKanbanProfileCache(); _profilePreFormDetail = null; await loadProfilesPanel(); showToast(t('profile_created', name)); @@ -4481,6 +4494,7 @@ async function deleteProfile(name) { if(!_delProf) return; try { await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) }); + _invalidateKanbanProfileCache(); await loadProfilesPanel(); showToast(t('profile_deleted', name)); } catch (e) { showToast(t('delete_failed') + e.message); } diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index 5171ac31..43ef1460 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -769,6 +769,44 @@ def test_kanban_active_board_persisted_to_localstorage(): assert "_kanbanSetSavedBoard" in PANELS +def test_kanban_profile_assignee_cache_has_invalidation_path(): + """Kanban assignee suggestions should stay aligned with profile mutations. + + The cache in _kanbanLoadProfileNames() can become stale when profiles are + created or deleted in the same session. This adds an explicit + invalidation path and a short TTL so modal opens recover from same-session + mutations and cross-tab/CLI changes. + """ + assert "_KANBAN_PROFILE_NAMES_CACHE_TTL_MS" in PANELS + assert "_kanbanProfileNamesCacheAt" in PANELS + assert "_invalidateKanbanProfileCache" in PANELS + + load_start = PANELS.find("async function _kanbanLoadProfileNames(){") + assert load_start != -1, "Missing _kanbanLoadProfileNames() declaration" + load_end = PANELS.find("\n}\n\nasync function _kanbanPopulateAssigneeSelect", load_start) + if load_end == -1: + load_end = PANELS.find("\n}\n\nfunction openKanbanCreate", load_start) + load_body = PANELS[load_start:load_end] if load_end != -1 else PANELS[load_start:load_start + 2200] + assert "Date.now() - _kanbanProfileNamesCacheAt" in load_body + assert "_kanbanProfileNamesCacheAt = Date.now()" in load_body + + save_start = PANELS.find("async function saveProfileForm(){") + assert save_start != -1, "Missing saveProfileForm() declaration" + save_end = PANELS.find("\n}\n\n// Back-compat", save_start) + save_body = PANELS[save_start:save_end if save_end != -1 else save_start + 2000] + assert "_invalidateKanbanProfileCache();" in save_body, ( + "Profile create flow should invalidate Kanban assignee cache after success." + ) + + delete_start = PANELS.find("async function deleteProfile(name) {") + assert delete_start != -1, "Missing deleteProfile() declaration" + delete_end = PANELS.find("\n\n// ── Memory panel", delete_start) + delete_body = PANELS[delete_start:delete_end if delete_end != -1 else delete_start + 1300] + assert "_invalidateKanbanProfileCache();" in delete_body, ( + "Profile delete flow should invalidate Kanban assignee cache after success." + ) + + def test_kanban_archive_board_uses_showConfirmDialog(): """Archive is destructive → must use the styled showConfirmDialog, not native confirm() (which can't be styled or i18n'd).""" From b67d2676e470db60d4c17c8f6e6c80d7752679b7 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 10 May 2026 14:40:46 +0800 Subject: [PATCH 04/12] fix(kanban): show original status hint in edit modal --- static/i18n.js | 1 + static/index.html | 1 + static/panels.js | 17 +++++++++++++++++ static/style.css | 6 ++++++ tests/test_kanban_ui_static.py | 8 ++++++++ 5 files changed, 33 insertions(+) diff --git a/static/i18n.js b/static/i18n.js index e1b2c986..e44f427e 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -543,6 +543,7 @@ const LOCALES = { kanban_title_required: 'Title is required.', kanban_new_task: 'New task', kanban_edit_task: 'Edit task', + kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.', 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', diff --git a/static/index.html b/static/index.html index 343d76bc..f558b406 100644 --- a/static/index.html +++ b/static/index.html @@ -1279,6 +1279,7 @@
+ populated from /api/profiles + board history, not a free-text input. Free-text invites typos that the dispatcher silently From 4c95d9274e6234d6445a09313c1baecf4dcf77dd Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 10 May 2026 15:03:21 +0800 Subject: [PATCH 07/12] test: add kanban modal locale parity regression --- tests/test_kanban_ui_static.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index 5171ac31..fc7220e3 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -472,6 +472,38 @@ def test_kanban_i18n_keys_exist_in_every_locale_block(): assert missing == [] +def test_kanban_modal_locale_parity(): + """Parity check for modal-facing Kanban i18n keys. + + Any locale that already contains modal-facing Kanban strings should include the + same set of modal vocabulary so new additions don't regress into locale gaps. + """ + locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S) + modal_keys = [ + "kanban_title", + "kanban_description", + "kanban_description_placeholder", + "kanban_status", + "kanban_assignee", + "kanban_assignee_placeholder", + "kanban_tenant", + "kanban_tenant_placeholder", + "kanban_priority", + "kanban_priority_hint", + "kanban_title_required", + ] + anchor_key = "kanban_no_comments" + missing = [ + f"{locale}:{key}" + for locale, body in locale_blocks + if re.search(rf"\b{re.escape(anchor_key)}\s*:", body) is not None + for key in modal_keys + if re.search(rf"\b{re.escape(key)}\s*:", body) is None + ] + assert missing == [] + + + def test_kanban_dashboard_parity_core_controls_are_native(): assert 'id="kanbanOnlyMine"' in INDEX From 2427f1e5988e56e139cd79ba8c35f775b3e83ea9 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 10 May 2026 15:48:18 +0800 Subject: [PATCH 08/12] test(kanban): harden locale-block parsing for quoted locales --- tests/test_kanban_ui_static.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index fc7220e3..9c37250d 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -11,6 +11,15 @@ COMPACT_PANELS = re.sub(r"\s+", "", PANELS) COMPACT_STYLE = re.sub(r"\s+", "", STYLE) +def _locale_blocks_with_body(i18n_text: str): + locale_blocks = re.findall( + r"\n\s*(?:'(?P[a-z]{2}(?:-[A-Z][A-Za-z]+)?)'|(?P[a-z]{2}(?:-[A-Z]{2})?))\s*:\s*\{(.*?)\n\s*\},", + i18n_text, + flags=re.S, + ) + return [(quoted or plain, body) for quoted, plain, body in locale_blocks] + + def test_kanban_has_native_sidebar_rail_and_mobile_tab(): assert 'data-panel="kanban"' in INDEX assert 'data-i18n-title="tab_kanban"' in INDEX @@ -439,8 +448,8 @@ def test_kanban_main_view_scrolls_when_task_preview_is_tall(): def test_kanban_i18n_keys_exist_in_every_locale_block(): - locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S) - assert len(locale_blocks) >= 8 + locale_blocks = _locale_blocks_with_body(I18N) + assert len(locale_blocks) >= 9 required_keys = [ "tab_kanban", "kanban_board", @@ -478,7 +487,7 @@ def test_kanban_modal_locale_parity(): Any locale that already contains modal-facing Kanban strings should include the same set of modal vocabulary so new additions don't regress into locale gaps. """ - locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S) + locale_blocks = _locale_blocks_with_body(I18N) modal_keys = [ "kanban_title", "kanban_description", @@ -492,7 +501,7 @@ def test_kanban_modal_locale_parity(): "kanban_priority_hint", "kanban_title_required", ] - anchor_key = "kanban_no_comments" + anchor_key = "kanban_status" missing = [ f"{locale}:{key}" for locale, body in locale_blocks @@ -536,7 +545,7 @@ def test_kanban_dashboard_parity_core_controls_are_native(): def test_kanban_dashboard_parity_i18n_keys_exist(): - locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S) + locale_blocks = _locale_blocks_with_body(I18N) required_keys = [ "kanban_only_mine", "kanban_bulk_action", @@ -607,7 +616,7 @@ def test_kanban_ui_parity_polish_css_and_i18n_exist(): ".hermes-kanban-md", ): assert selector in STYLE - locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S) + locale_blocks = _locale_blocks_with_body(I18N) required_keys = ["kanban_lanes_by_profile", "kanban_card_complete", "kanban_card_archive", "kanban_unassigned", "kanban_work_queue_hint"] missing = [ f"{locale}:{key}" From ba51efec265d486206347844668af6b0a3269996 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 10 May 2026 15:49:14 +0800 Subject: [PATCH 09/12] test(kanban): assert profile-cache invalidation on profile delete --- static/panels.js | 1 + tests/test_kanban_ui_static.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/static/panels.js b/static/panels.js index 1bc3f106..44670377 100644 --- a/static/panels.js +++ b/static/panels.js @@ -4196,6 +4196,7 @@ async function deleteCurrentProfile(){ if(!_ok) return; try { await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) }); + _invalidateKanbanProfileCache(); _clearProfileDetail(); await loadProfilesPanel(); showToast(t('profile_deleted', name)); diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index 43ef1460..1d31d1d2 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -806,6 +806,14 @@ def test_kanban_profile_assignee_cache_has_invalidation_path(): "Profile delete flow should invalidate Kanban assignee cache after success." ) + ui_delete_start = PANELS.find("async function deleteCurrentProfile(){") + assert ui_delete_start != -1, "Missing deleteCurrentProfile() declaration" + ui_delete_end = PANELS.find("\n\nfunction renderProfileDropdown", ui_delete_start) + ui_delete_body = PANELS[ui_delete_start:ui_delete_end if ui_delete_end != -1 else ui_delete_start + 1300] + assert "_invalidateKanbanProfileCache();" in ui_delete_body, ( + "Profile detail delete flow (deleteCurrentProfile) should invalidate Kanban assignee cache after success." + ) + def test_kanban_archive_board_uses_showConfirmDialog(): """Archive is destructive → must use the styled showConfirmDialog, From 52c1053baa184f3306c451c63a66e873ee8fbd37 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 10 May 2026 17:00:40 +0000 Subject: [PATCH 10/12] =?UTF-8?q?chore:=20CHANGELOG=20for=20v0.51.35=20?= =?UTF-8?q?=E2=80=94=20Release=20K=20(kanban=20polish=20+=20i18n=20DE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f60fbcd..29315b84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Hermes Web UI -- Changelog +## [v0.51.35] — 2026-05-10 — Release K (kanban polish + i18n DE pluralization) + +### Fixed + +- **PR #1990** by @franksong2702 — Kanban dispatcher race guard. Adds `_kanbanIsDispatching` flag around `runKanbanDispatcher()` and `nudgeKanbanDispatcher()` in `static/panels.js`; both Run/Preview buttons go disabled while the call is in-flight, so a fast double-click can't fire the dispatcher twice (which would post duplicate POSTs and surface duplicate toasts). Re-enables on success or error in `finally`. Closes #1984. + +- **PR #1991** by @franksong2702 — German `profile_skill_count` pluralization. The DE locale had `profile_skill_count: '{count} Fähigkeiten'` as a literal string with the placeholder token still in it (so 1, 2, 5 skills all rendered as `{count} Fähigkeiten`). Switched to the same `(count) => …` interpolation function form already used by the other locales. Regression test `tests/test_issue1989_profile_skill_count.py` pins DE to function form and asserts the literal token never reaches the rendered string. Closes #1989. + +- **PR #1993** by @franksong2702 — Kanban assignee-dropdown profile cache invalidation. `_kanbanProfileNamesCache` was populated lazily on first modal open and never expired; creating or deleting a profile elsewhere in the UI didn't refresh it, so the assignee dropdown could show a freshly-deleted profile or miss a freshly-created one. Added a 30-second TTL (`_kanbanProfileNamesCacheAt` + `_KANBAN_PROFILE_NAMES_CACHE_TTL_MS`) and an explicit `_invalidateKanbanProfileCache()` helper called from `saveProfileForm()`, `deleteCurrentProfile()`, and `deleteProfile()`. Closes #1985. + +- **PR #1995** by @franksong2702 — Kanban modal focus trap + edit-mode status hint. Two related fixes bundled (#1995 was rebased on top of #1994 in the contributor's branch): + - **Focus trap (#1974).** Tab/Shift-Tab in the Kanban task and board modals could move keyboard focus to controls behind the modal. Added a shared `_trapModalFocus(modalEl)` helper in `static/panels.js`; wired into `openKanbanCreate()`, `openKanbanEdit()`, `openKanbanCreateBoard()`, and `openKanbanRenameBoard()`. Cleanup tracker `_kanbanTaskModalFocusCleanup` removes the trap on close so a sequence of open→close→open doesn't leak listeners. + - **Status hint (#1986).** When opening Edit on a task whose real status is `running`/`blocked`/`done`/`archived` (which the dropdown displays as `triage` because the dispatcher only writes to `triage`/`todo`/`ready`), the modal now shows an inline hint explaining the displayed-vs-real mismatch. The dropdown behaviour is unchanged — only an additional UX cue. New CSS for `.kanban-status-hint`, new i18n key `kanban_status_hint_real` across all 8 locales. + + Closes #1974, #1986. + +- **PR #1996** by @franksong2702 — Kanban modal locale parity regression test. Adds `tests/test_kanban_ui_static.py::test_kanban_modal_locales_have_full_modal_vocabulary` that anchors on the existing `kanban_no_comments` key and asserts every locale supporting Kanban has the modal vocabulary. Hardens locale-block parsing to handle quoted locales. Pure test addition. + +### Tests + +5049 → **5054 collected, 5054 passing, 0 regressions** (+5 net new). Full suite 154s on Python 3.11 with `HERMES_HOME` isolation. + +### Notes + +- `static/panels.js` was the high-collision file in this batch (5 PRs touched it). Stage merge cleanly; one syntactic conflict at the `_kanbanProfileNamesCache` declaration block when #1995 landed on top of #1993 — both PRs added new module-level `let` declarations adjacent to `_kanbanProfileNamesCache`. Resolved by preserving both declaration blocks (the variables are independent). +- Six PRs in batch, all from @franksong2702. Disjoint concerns, disjoint i18n keys, disjoint tests. The 5-files panels.js overlap was the only nontrivial integration risk and resolved cleanly. + ## [v0.51.34] — 2026-05-09 — Release J (kanban edit/dispatch + zh-Hant kanban i18n) ### Added From 9242305a819fc8bb2abfc77741747124b396bab3 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 10 May 2026 17:06:10 +0000 Subject: [PATCH 11/12] fix(stage-329): zh-Hant locale parity for kanban_status_original_hint + extend locale parity test (Opus advisor SHIP-WITH-CAVEATS follow-up) --- static/i18n.js | 1 + tests/test_kanban_ui_static.py | 1 + 2 files changed, 2 insertions(+) diff --git a/static/i18n.js b/static/i18n.js index de28507c..6d691189 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -6535,6 +6535,7 @@ const LOCALES = { kanban_description: '描述', kanban_description_placeholder: '選填 — 需要完成的事項、驗收標準、連結', kanban_status: '狀態', + kanban_status_original_hint: '實際狀態:{0}。此對話框僅支援編輯 Triage/Todo/Ready。', kanban_assignee: '指派對象', kanban_assignee_placeholder: '選填 — 個人資料或名稱', kanban_tenant: '租戶', diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index 8a0f2622..dcdfd52a 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -593,6 +593,7 @@ def test_kanban_modal_locale_parity(): "kanban_priority", "kanban_priority_hint", "kanban_title_required", + "kanban_status_original_hint", ] anchor_key = "kanban_status" missing = [ From 941c8051a92801b47065560bde0301f2f3af47e6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 10 May 2026 17:06:27 +0000 Subject: [PATCH 12/12] chore: CHANGELOG note for stage augmentation 9242305a --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29315b84..235c92d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ 5049 → **5054 collected, 5054 passing, 0 regressions** (+5 net new). Full suite 154s on Python 3.11 with `HERMES_HOME` isolation. +### Stage augmentation + +- **`9242305a`** — Opus advisor flagged that `kanban_status_original_hint` (added by #1995) was missing in the `zh-Hant` block, so Traditional Chinese users would get the English fallback. Added the Traditional Chinese translation (`實際狀態:{0}。此對話框僅支援編輯 Triage/Todo/Ready。`) at line 6537 and extended `tests/test_kanban_ui_static.py::test_kanban_modal_locales_have_full_modal_vocabulary`'s `modal_keys` list to assert the key — so any future kanban modal key added without zh-Hant translation will fail CI. + ### Notes - `static/panels.js` was the high-collision file in this batch (5 PRs touched it). Stage merge cleanly; one syntactic conflict at the `_kanbanProfileNamesCache` declaration block when #1995 landed on top of #1993 — both PRs added new module-level `let` declarations adjacent to `_kanbanProfileNamesCache`. Resolved by preserving both declaration blocks (the variables are independent).