diff --git a/static/panels.js b/static/panels.js index 1f8787ed..ec549f6a 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1704,6 +1704,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 @@ -1714,9 +1720,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 : []; @@ -1728,9 +1738,11 @@ async function _kanbanLoadProfileNames(){ return a.localeCompare(b); }); _kanbanProfileNamesCache = names; + _kanbanProfileNamesCacheAt = Date.now(); return names; } catch(_) { _kanbanProfileNamesCache = []; + _kanbanProfileNamesCacheAt = Date.now(); return []; } } @@ -4209,6 +4221,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)); @@ -4486,6 +4499,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)); @@ -4506,6 +4520,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 38ac42d7..53a4b91d 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -801,6 +801,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)."""