diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f60fbcd..235c92d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,36 @@
# 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.
+
+### 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).
+- 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
diff --git a/static/i18n.js b/static/i18n.js
index e1b2c986..6d691189 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',
@@ -1589,6 +1590,7 @@ const LOCALES = {
kanban_status_running: '実行中',
kanban_status_blocked: 'ブロック中',
kanban_status_done: '完了',
+ kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'コメント ({0})',
kanban_events_count: 'イベント ({0})',
kanban_links: 'リンク',
@@ -2490,6 +2492,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
@@ -3492,6 +3495,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
@@ -4482,6 +4486,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
@@ -5027,7 +5032,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',
@@ -5493,6 +5498,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
@@ -6529,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: '租戶',
@@ -7648,6 +7655,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
@@ -8614,6 +8622,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
+ kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
diff --git a/static/index.html b/static/index.html
index 343d76bc..80421fce 100644
--- a/static/index.html
+++ b/static/index.html
@@ -160,8 +160,8 @@
Status Ready Blocked Done Archived
Bulk action
- Preview
- Run dispatcher
+ Preview
+ Run dispatcher
@@ -719,7 +719,7 @@
@@ -1279,6 +1279,7 @@
Status
+
Triage
Todo
diff --git a/static/panels.js b/static/panels.js
index 8bdc815a..7f73e503 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){
@@ -1679,6 +1704,13 @@ 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;
+}
+let _kanbanTaskModalFocusCleanup = null;
// 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
@@ -1687,11 +1719,16 @@ let _kanbanProfileNamesCache = null; // populated lazily on first modal open
// review: editing a 'running' task without touching status was reclaiming
// the worker and moving the task back to triage.
let _kanbanTaskModalInitialDisplayedStatus = null;
+let _kanbanBoardModalFocusCleanup = 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 +1740,11 @@ async function _kanbanLoadProfileNames(){
return a.localeCompare(b);
});
_kanbanProfileNamesCache = names;
+ _kanbanProfileNamesCacheAt = Date.now();
return names;
} catch(_) {
_kanbanProfileNamesCache = [];
+ _kanbanProfileNamesCacheAt = Date.now();
return [];
}
}
@@ -1771,6 +1810,7 @@ function openKanbanCreate(){
// tasks that need human review before being marked actionable; users who
// want it can still pick it from the status dropdown.
_kanbanResetTaskModalFields({status: 'ready'});
+ _kanbanSetTaskModalStatusHint(null);
_kanbanSetTaskModalLabels('create');
_kanbanPopulateAssigneeSelect('').then(() => {
// After the dropdown is populated, default-select the first profile (not
@@ -1784,6 +1824,11 @@ function openKanbanCreate(){
});
_kanbanPopulateTenantDatalist();
modal.hidden = false;
+ if (_kanbanTaskModalFocusCleanup) {
+ _kanbanTaskModalFocusCleanup();
+ _kanbanTaskModalFocusCleanup = null;
+ }
+ _kanbanTaskModalFocusCleanup = _trapModalFocus(modal);
setTimeout(() => {
const titleEl = document.getElementById('kanbanTaskModalTitleInput');
if (titleEl) titleEl.focus();
@@ -1817,6 +1862,7 @@ async function openKanbanEdit(taskId){
// (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);
+ const originalStatus = task.status || initialDisplayedStatus;
_kanbanTaskModalInitialDisplayedStatus = initialDisplayedStatus;
_kanbanResetTaskModalFields({
title: task.title || '',
@@ -1828,9 +1874,15 @@ async function openKanbanEdit(taskId){
// Populate the assignee select AFTER reset so the option exists when we
// call sel.value = currentAssignee.
await _kanbanPopulateAssigneeSelect(task.assignee || '');
+ _kanbanSetTaskModalStatusHint(originalStatus, initialDisplayedStatus);
_kanbanSetTaskModalLabels('edit');
_kanbanPopulateTenantDatalist();
modal.hidden = false;
+ if (_kanbanTaskModalFocusCleanup) {
+ _kanbanTaskModalFocusCleanup();
+ _kanbanTaskModalFocusCleanup = null;
+ }
+ _kanbanTaskModalFocusCleanup = _trapModalFocus(modal);
setTimeout(() => {
const titleEl = document.getElementById('kanbanTaskModalTitleInput');
if (titleEl) { titleEl.focus(); titleEl.select(); }
@@ -1879,18 +1931,74 @@ function _kanbanSetTaskModalLabels(mode){
}
}
+function _kanbanSetTaskModalStatusHint(realStatus, editableStatus){
+ const hintEl = document.getElementById('kanbanTaskModalStatusOriginalHint');
+ if (!hintEl) return;
+ if (!realStatus || realStatus === editableStatus) {
+ hintEl.hidden = true;
+ hintEl.textContent = '';
+ return;
+ }
+ const statusLabel = t(`kanban_status_${realStatus}`) || realStatus;
+ hintEl.textContent = String(t('kanban_status_original_hint')).replace('{0}', statusLabel);
+ hintEl.hidden = false;
+}
+
function _kanbanPopulateTenantDatalist(){
const tenants = (_kanbanBoard && Array.isArray(_kanbanBoard.tenants)) ? _kanbanBoard.tenants : [];
const tList = document.getElementById('kanbanTaskModalTenantList');
if (tList) tList.innerHTML = tenants.map(v => ` `).join('');
}
+function _trapModalFocus(modalEl){
+ if (!modalEl) return () => {};
+ const selector = 'a[href], button, textarea, input, select, summary, [tabindex]:not([tabindex="-1"])';
+ const collect = () => {
+ const candidates = Array.from(modalEl.querySelectorAll(selector));
+ return candidates.filter((el) => {
+ if (el.disabled || el.hidden) return false;
+ const style = getComputedStyle(el);
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
+ return el.tabIndex >= 0;
+ });
+ };
+ let focusableEls = collect();
+ const onKeyDown = (ev) => {
+ if (ev.key !== 'Tab') return;
+ if (!focusableEls.length) {
+ ev.preventDefault();
+ return;
+ }
+ const current = document.activeElement;
+ let idx = focusableEls.indexOf(current);
+ if (idx === -1) {
+ ev.preventDefault();
+ focusableEls[0].focus();
+ return;
+ }
+ if (ev.shiftKey) idx -= 1;
+ else idx += 1;
+ idx = (idx + focusableEls.length) % focusableEls.length;
+ ev.preventDefault();
+ focusableEls[idx].focus();
+ };
+ modalEl.addEventListener('keydown', onKeyDown);
+ return () => {
+ modalEl.removeEventListener('keydown', onKeyDown);
+ };
+}
+
function closeKanbanTaskModal(){
const modal = document.getElementById('kanbanTaskModal');
if (modal) modal.hidden = true;
_kanbanTaskModalMode = 'create';
_kanbanTaskModalEditingId = null;
_kanbanTaskModalInitialDisplayedStatus = null;
+ _kanbanSetTaskModalStatusHint(null, null);
+ if (_kanbanTaskModalFocusCleanup) {
+ _kanbanTaskModalFocusCleanup();
+ _kanbanTaskModalFocusCleanup = null;
+ }
document.removeEventListener('keydown', _kanbanTaskModalKey);
}
@@ -2329,6 +2437,11 @@ function openKanbanCreateBoard(){
document.getElementById('kanbanBoardModalColor').value = '#7aa2ff';
document.getElementById('kanbanBoardModalError').textContent = '';
modal.hidden = false;
+ if (_kanbanBoardModalFocusCleanup) {
+ _kanbanBoardModalFocusCleanup();
+ _kanbanBoardModalFocusCleanup = null;
+ }
+ _kanbanBoardModalFocusCleanup = _trapModalFocus(modal);
// Auto-focus name field
setTimeout(() => document.getElementById('kanbanBoardModalName').focus(), 50);
// Auto-suggest slug from name as user types
@@ -2368,6 +2481,11 @@ function openKanbanRenameBoard(){
document.getElementById('kanbanBoardModalColor').value = meta.color || '#7aa2ff';
document.getElementById('kanbanBoardModalError').textContent = '';
modal.hidden = false;
+ if (_kanbanBoardModalFocusCleanup) {
+ _kanbanBoardModalFocusCleanup();
+ _kanbanBoardModalFocusCleanup = null;
+ }
+ _kanbanBoardModalFocusCleanup = _trapModalFocus(modal);
setTimeout(() => document.getElementById('kanbanBoardModalName').focus(), 50);
document.addEventListener('keydown', _kanbanBoardModalEsc);
}
@@ -2379,6 +2497,10 @@ function _kanbanBoardModalEsc(ev){
function closeKanbanBoardModal(){
const modal = document.getElementById('kanbanBoardModal');
if (modal) modal.hidden = true;
+ if (_kanbanBoardModalFocusCleanup) {
+ _kanbanBoardModalFocusCleanup();
+ _kanbanBoardModalFocusCleanup = null;
+ }
document.removeEventListener('keydown', _kanbanBoardModalEsc);
}
@@ -4184,6 +4306,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));
@@ -4461,6 +4584,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 +4605,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/static/style.css b/static/style.css
index 220bf473..b89b0da4 100644
--- a/static/style.css
+++ b/static/style.css
@@ -3521,6 +3521,12 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.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-status-original-hint{
+ display:block;
+ margin-top:4px;
+ font-size:11px;
+ color:var(--muted);
+}
.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;}
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"
+ )
diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py
index 5171ac31..dcdfd52a 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
@@ -257,6 +266,10 @@ def test_kanban_edit_mode_preserves_status_when_dropdown_untouched():
"Edit-mode status preservation requires tracking the initial displayed "
"status so submit can detect whether the user actually changed it."
)
+ assert 'id="kanbanTaskModalStatusOriginalHint"' in INDEX
+ assert "_kanbanSetTaskModalStatusHint" in PANELS
+ assert "kanban_status_original_hint" in I18N
+ assert ".kanban-status-original-hint" in STYLE
# 2. openKanbanEdit captures the initial displayed status from the task.
open_edit_match = re.search(
@@ -268,6 +281,8 @@ def test_kanban_edit_mode_preserves_status_when_dropdown_untouched():
"openKanbanEdit must record the initial displayed status."
)
assert "_kanbanEditableStatusFor(task.status)" in open_edit_body
+ assert "_kanbanSetTaskModalStatusHint(originalStatus, initialDisplayedStatus)" in open_edit_body
+ assert "const originalStatus = task.status || initialDisplayedStatus" in open_edit_body
# 3. Submit's edit branch only sends status when it differs from the
# initial displayed value.
@@ -292,6 +307,7 @@ def test_kanban_edit_mode_preserves_status_when_dropdown_untouched():
"openKanbanCreate must reset the tracker to null so create-mode "
"submits always include status in the POST payload."
)
+ assert "_kanbanSetTaskModalStatusHint(null);" in create_body
# 5. closeKanbanTaskModal clears the tracker so a stale value can't leak
# into the next open.
@@ -301,6 +317,60 @@ def test_kanban_edit_mode_preserves_status_when_dropdown_untouched():
assert close_match
close_body = close_match.group(1)
assert "_kanbanTaskModalInitialDisplayedStatus = null" in close_body
+ assert "_kanbanSetTaskModalStatusHint(null, null);" in close_body
+
+
+def test_kanban_modal_focus_trap_helper_exists():
+ """Shared focus-trap helper should exist and attach/remove Tab key handling."""
+ assert "function _trapModalFocus" in PANELS
+ fn = re.search(r"function _trapModalFocus\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
+ assert fn, "_trapModalFocus() not found"
+ fn_body = fn.group(1)
+ assert "addEventListener('keydown'" in fn_body
+ assert "removeEventListener('keydown'" in fn_body
+ assert "ev.key !== 'Tab'" in fn_body or "ev.key === 'Tab'" in fn_body
+
+
+def test_kanban_task_modal_focus_trap_is_installed_and_removed():
+ """Task modal open calls should install focus trap and close should tear it down."""
+ create_match = re.search(r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
+ assert create_match, "openKanbanCreate() not found"
+ create_body = create_match.group(1)
+ assert "_kanbanTaskModalFocusCleanup = _trapModalFocus(modal);" in create_body
+ assert "if (_kanbanTaskModalFocusCleanup) {" in create_body
+
+ edit_match = re.search(r"async function openKanbanEdit\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
+ assert edit_match, "openKanbanEdit() not found"
+ edit_body = edit_match.group(1)
+ assert "_kanbanTaskModalFocusCleanup = _trapModalFocus(modal);" in edit_body
+ assert "if (_kanbanTaskModalFocusCleanup) {" in edit_body
+
+ close_match = re.search(r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
+ assert close_match, "closeKanbanTaskModal() not found"
+ close_body = close_match.group(1)
+ assert "if (_kanbanTaskModalFocusCleanup) {" in close_body
+ assert "_kanbanTaskModalFocusCleanup = null;" in close_body
+
+
+def test_kanban_board_modal_focus_trap_is_installed_and_removed():
+ """Board modal open calls should install focus trap and close should tear it down."""
+ create_board_match = re.search(r"function openKanbanCreateBoard\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
+ assert create_board_match, "openKanbanCreateBoard() not found"
+ create_board_body = create_board_match.group(1)
+ assert "_kanbanBoardModalFocusCleanup = _trapModalFocus(modal);" in create_board_body
+ assert "if (_kanbanBoardModalFocusCleanup) {" in create_board_body
+
+ rename_board_match = re.search(r"function openKanbanRenameBoard\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
+ assert rename_board_match, "openKanbanRenameBoard() not found"
+ rename_board_body = rename_board_match.group(1)
+ assert "_kanbanBoardModalFocusCleanup = _trapModalFocus(modal);" in rename_board_body
+ assert "if (_kanbanBoardModalFocusCleanup) {" in rename_board_body
+
+ close_board_match = re.search(r"function closeKanbanBoardModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
+ assert close_board_match, "closeKanbanBoardModal() not found"
+ close_board_body = close_board_match.group(1)
+ assert "if (_kanbanBoardModalFocusCleanup) {" in close_board_body
+ assert "_kanbanBoardModalFocusCleanup = null;" in close_board_body
def test_kanban_assignee_dropdown_uses_select_not_freetext():
@@ -413,6 +483,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 (
@@ -439,8 +541,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",
@@ -472,6 +574,39 @@ 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 = _locale_blocks_with_body(I18N)
+ 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",
+ "kanban_status_original_hint",
+ ]
+ anchor_key = "kanban_status"
+ 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
@@ -504,7 +639,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",
@@ -575,7 +710,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}"
@@ -769,6 +904,52 @@ 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."
+ )
+
+ 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,
not native confirm() (which can't be styled or i18n'd)."""